Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03

# Conflicts:
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-05-14 19:17:17 +08:00
495 changed files with 40663 additions and 5654 deletions

View File

@@ -7,12 +7,14 @@ use super::{
sanitize_identity_component,
},
tables::{
AuthIdentity, AuthStoreSnapshot, RefreshSession, UserAccount, auth_identity,
auth_store_snapshot, refresh_session, user_account,
AuthIdentity, AuthStoreProjectionMeta, AuthStoreSnapshot, RefreshSession, UserAccount,
auth_identity, auth_store_projection_meta, auth_store_snapshot, refresh_session,
user_account,
},
};
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
const AUTH_STORE_PROJECTION_META_ID: &str = "default";
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotRecord {
@@ -70,7 +72,7 @@ pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotP
}
}
// Axum 每次鉴权仓储变更后覆盖写入整份快照,后续拆表阶段再替换为细粒度 reducer
// 历史迁移入口:覆盖写入整份快照,供旧库从 `auth_store_snapshot/default` 导入正式表
#[spacetimedb::procedure]
pub fn upsert_auth_store_snapshot(
ctx: &mut ProcedureContext,
@@ -90,6 +92,26 @@ pub fn upsert_auth_store_snapshot(
}
}
// Axum 运行期认证变更直接导入正式认证表,不再继续刷新 `auth_store_snapshot/default`。
#[spacetimedb::procedure]
pub fn import_auth_store_snapshot_json(
ctx: &mut ProcedureContext,
input: AuthStoreSnapshotUpsertInput,
) -> AuthStoreSnapshotImportProcedureResult {
match ctx.try_with_tx(|tx| import_auth_store_snapshot_json_tx(tx, input.clone())) {
Ok(record) => AuthStoreSnapshotImportProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AuthStoreSnapshotImportProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn import_auth_store_snapshot(
ctx: &mut ProcedureContext,
@@ -191,10 +213,35 @@ fn import_auth_store_snapshot_tx(
.snapshot_id()
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
.ok_or_else(|| "认证快照不存在,无法导入正式表".to_string())?;
let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(&snapshot.snapshot_json)
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,
) -> Result<AuthStoreSnapshotImportRecord, String> {
import_auth_store_snapshot_json_value_tx(ctx, &input.snapshot_json, input.updated_at_micros)
}
fn import_auth_store_snapshot_json_value_tx(
ctx: &ReducerContext,
snapshot_json: &str,
updated_at_micros: i64,
) -> Result<AuthStoreSnapshotImportRecord, String> {
let snapshot_json = snapshot_json.trim();
if snapshot_json.is_empty() {
return Err("认证快照 JSON 不能为空".to_string());
}
let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
.map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?;
clear_auth_target_tables(ctx);
upsert_auth_projection_meta(ctx, updated_at_micros);
let mut imported_user_count = 0_u32;
let mut imported_identity_count = 0_u32;
@@ -293,6 +340,12 @@ fn export_auth_store_snapshot_from_tables_tx(
updated_at_micros: None,
});
}
let updated_at_micros = ctx
.db
.auth_store_projection_meta()
.meta_id()
.find(&AUTH_STORE_PROJECTION_META_ID.to_string())
.map(|row| row.updated_at.to_micros_since_unix_epoch());
let mut phone_identity_by_user_id = std::collections::HashMap::new();
let mut phone_to_user_id = std::collections::HashMap::new();
@@ -407,7 +460,7 @@ fn export_auth_store_snapshot_from_tables_tx(
Ok(AuthStoreSnapshotRecord {
snapshot_json: Some(snapshot_json),
updated_at_micros: None,
updated_at_micros,
})
}
@@ -428,3 +481,25 @@ fn clear_auth_target_tables(ctx: &ReducerContext) {
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
.db
.auth_store_projection_meta()
.meta_id()
.find(&meta_id)
.is_some()
{
ctx.db
.auth_store_projection_meta()
.meta_id()
.delete(&meta_id);
}
ctx.db
.auth_store_projection_meta()
.insert(AuthStoreProjectionMeta {
meta_id,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
}

View File

@@ -8,6 +8,13 @@ pub struct AuthStoreSnapshot {
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(accessor = auth_store_projection_meta)]
pub struct AuthStoreProjectionMeta {
#[primary_key]
pub(crate) meta_id: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = user_account,
index(accessor = by_user_account_username, btree(columns = [username])),

View File

@@ -16,7 +16,7 @@ pub struct CustomWorldProfile {
owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
// 作者公开百梦号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,

View File

@@ -13,13 +13,12 @@ use module_match3d::{
Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState,
Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus,
Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at_and_item_type_count,
stop_run_at as stop_domain_run_at,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
const MATCH3D_GENERATED_ITEM_COUNT_MVP: u32 = 3;
use serde_json::Value;
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
@@ -499,7 +498,7 @@ fn compile_match3d_draft_tx(
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
source_session_id: input.session_id.clone(),
author_display_name: clean_string(&input.author_display_name, "百梦"),
author_display_name: clean_string(&input.author_display_name, "陶泥儿"),
game_name,
theme_text: config.theme_text.clone(),
summary_text,
@@ -722,7 +721,12 @@ fn start_match3d_run_tx(
} else {
current_server_ms(ctx)
};
let mut snapshot = build_initial_run_snapshot(&input.run_id, &work, started_at_ms);
let mut snapshot = build_initial_run_snapshot(
&input.run_id,
&work,
started_at_ms,
normalize_match3d_item_type_count_override(input.item_type_count_override),
);
snapshot.server_now_ms = current_server_ms(ctx);
snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms);
let now = ctx.timestamp;
@@ -838,6 +842,7 @@ fn restart_match3d_run_tx(
input: Match3DRunRestartInput,
) -> Result<Match3DRunSnapshot, String> {
let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?;
let item_type_count_override = resolve_item_type_count_override_from_run(&source);
start_match3d_run_tx(
ctx,
Match3DRunStartInput {
@@ -845,6 +850,7 @@ fn restart_match3d_run_tx(
owner_user_id: input.owner_user_id,
profile_id: source.profile_id,
started_at_ms: input.restarted_at_ms,
item_type_count_override,
},
)
}
@@ -992,19 +998,25 @@ fn build_initial_run_snapshot(
run_id: &str,
work: &Match3DWorkProfileRow,
started_at_ms: i64,
item_type_count_override: Option<u32>,
) -> Match3DRunSnapshot {
let config = parse_config_or_default(&work.config_json);
let domain_config =
let mut domain_config =
domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config());
domain_config.clear_count = module_match3d::normalize_match3d_runtime_clear_count(
domain_config.clear_count,
domain_config.difficulty,
);
let domain_started_at_ms = to_u64_ms(started_at_ms);
let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty);
let domain_run = start_run_with_seed_at(
let domain_run = start_run_with_seed_at_and_item_type_count(
run_id.to_string(),
work.owner_user_id.clone(),
work.profile_id.clone(),
&domain_config,
seed,
domain_started_at_ms,
item_type_count_override,
)
.unwrap_or_else(|_| DomainMatch3DRunSnapshot {
run_id: run_id.to_string(),
@@ -1026,6 +1038,26 @@ fn build_initial_run_snapshot(
snapshot_from_domain(&domain_run, started_at_ms)
}
fn normalize_match3d_item_type_count_override(value: u32) -> Option<u32> {
(value > 0).then_some(value)
}
fn resolve_item_type_count_override_from_run(row: &Match3DRuntimeRunRow) -> u32 {
deserialize_snapshot(&row.snapshot_json)
.ok()
.map(|snapshot| {
let mut item_type_ids = snapshot
.items
.iter()
.map(|item| item.item_type_id.clone())
.collect::<Vec<_>>();
item_type_ids.sort();
item_type_ids.dedup();
item_type_ids.len() as u32
})
.unwrap_or(0)
}
fn fallback_domain_config() -> DomainMatch3DCreatorConfig {
DomainMatch3DCreatorConfig {
theme_text: "经典消除".to_string(),
@@ -1218,7 +1250,19 @@ fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String>
if parse_tags(&row.tags_json)?.is_empty() {
return Err("match3d 发布需要至少 1 个标签".to_string());
}
validate_config(&parse_config(&row.config_json)?)
let config = parse_config(&row.config_json)?;
let required_item_types =
module_match3d::resolve_match3d_item_type_count_for_difficulty(
config.clear_count,
config.difficulty,
) as usize;
let ready_item_types = count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?;
if ready_item_types < required_item_types {
return Err(format!(
"match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types}"
));
}
validate_config(&config)
}
fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool {
@@ -1234,6 +1278,7 @@ fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}
}
@@ -1255,10 +1300,8 @@ fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
}
fn normalize_match3d_generated_item_config(
mut config: Match3DCreatorConfigSnapshot,
config: Match3DCreatorConfigSnapshot,
) -> Match3DCreatorConfigSnapshot {
// 中文注释:素材生成首版任意难度都只生成 3 件物品,草稿编译也同步收敛。
config.clear_count = MATCH3D_GENERATED_ITEM_COUNT_MVP;
config
}
@@ -1284,6 +1327,47 @@ fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<St
Ok(Some(to_json_string(&parsed)))
}
fn count_ready_generated_item_types(value: Option<&str>) -> Result<usize, String> {
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(0);
};
let parsed = parse_json::<Vec<Value>>(trimmed, "match3d generated_item_assets_json")?;
Ok(parsed
.iter()
.filter(|asset| {
let status_ready = asset
.get("status")
.and_then(Value::as_str)
.map(|status| status == "image_ready")
.unwrap_or(false);
let view_count = asset
.get("imageViews")
.or_else(|| asset.get("image_views"))
.and_then(Value::as_array)
.map(|views| {
views
.iter()
.filter(|view| {
view.get("imageSrc")
.or_else(|| view.get("image_src"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
|| view
.get("imageObjectKey")
.or_else(|| view.get("image_object_key"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
})
.count()
})
.unwrap_or(0);
status_ready && view_count >= 5
})
.count())
}
fn resolve_generated_item_assets_json_for_compile(
input: Option<&str>,
existing_work: Option<&Match3DWorkProfileRow>,
@@ -1695,6 +1779,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -1702,7 +1787,7 @@ mod tests {
published_at: None,
generated_item_assets_json: None,
};
let snapshot = build_initial_run_snapshot("run-1", &work, 10);
let snapshot = build_initial_run_snapshot("run-1", &work, 10, None);
assert_eq!(snapshot.total_item_count, 12);
assert_eq!(snapshot.items.len(), 12);
}
@@ -1730,6 +1815,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -1774,6 +1860,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
@@ -1794,6 +1881,68 @@ mod tests {
);
}
#[test]
fn match3d_publish_ready_requires_five_image_views_per_item() {
let base_work = Match3DWorkProfileRow {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: "session-1".to_string(),
author_display_name: "作者".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags_json: "[\"水果\"]".to_string(),
cover_image_src: "/cover.png".to_string(),
cover_asset_id: String::new(),
clear_count: 8,
difficulty: 2,
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 8,
difficulty: 2,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"#
.to_string(),
),
};
let error = validate_publishable_work(&base_work).unwrap_err();
assert!(error.contains("当前已有 0 种"));
let ready_assets = (1..=3)
.map(|index| {
let views = (1..=5)
.map(|view_index| {
format!(
r#"{{"imageSrc":"/generated-match3d-assets/session/profile/items/i{index}/views/view-{view_index:02}.png"}}"#
)
})
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"itemId":"match3d-item-{index}","itemName":"物品{index}","imageViews":[{views}],"status":"image_ready"}}"#
)
})
.collect::<Vec<_>>()
.join(",");
let ready_work = Match3DWorkProfileRow {
generated_item_assets_json: Some(format!("[{ready_assets}]")),
..base_work
};
assert!(validate_publishable_work(&ready_work).is_ok());
}
#[test]
fn match3d_compile_without_metadata_payload_preserves_existing_metadata() {
let existing = Match3DWorkProfileRow {
@@ -1817,6 +1966,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
@@ -1846,7 +1996,7 @@ mod tests {
}
#[test]
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
fn match3d_compile_keeps_difficulty_clear_count() {
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
@@ -1855,10 +2005,12 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: true,
});
assert_eq!(config.clear_count, MATCH3D_GENERATED_ITEM_COUNT_MVP);
assert_eq!(config.clear_count, 20);
assert_eq!(config.difficulty, 8);
assert!(config.generate_click_sound);
}
#[test]

View File

@@ -138,6 +138,7 @@ pub struct Match3DRunStartInput {
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
pub item_type_count_override: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -230,6 +231,8 @@ pub struct Match3DCreatorConfigSnapshot {
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
#[serde(default)]
pub generate_click_sound: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -163,6 +163,7 @@ macro_rules! migration_tables {
$macro_name! {
$($arg,)*
auth_store_snapshot,
auth_store_projection_meta,
user_account,
auth_identity,
refresh_session,
@@ -1163,6 +1164,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
}
}
if table_name == "profile_recharge_order" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:真实微信支付接入后才有平台交易号,旧迁移包按未回填处理。
object
.entry("provider_transaction_id".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。

View File

@@ -16,7 +16,8 @@ use module_puzzle::{
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput,
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
@@ -314,6 +315,25 @@ pub fn save_puzzle_generated_images(
}
}
#[spacetimedb::procedure]
pub fn save_puzzle_ui_background(
ctx: &mut ProcedureContext,
input: PuzzleUiBackgroundSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_ui_background_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn select_puzzle_cover_image(
ctx: &mut ProcedureContext,
@@ -986,7 +1006,7 @@ fn save_puzzle_generated_images_tx(
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("百梦")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("陶泥儿")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -1026,6 +1046,70 @@ fn save_puzzle_generated_images_tx(
)
}
fn save_puzzle_ui_background_tx(
ctx: &TxContext,
input: PuzzleUiBackgroundSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
draft.levels = levels;
module_puzzle::sync_primary_level_fields(&mut draft);
}
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
next_level.ui_background_prompt = Some(input.prompt.trim().to_string());
next_level.ui_background_image_src = Some(input.image_src.trim().to_string());
next_level.ui_background_image_object_key = input
.image_object_key
.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent.max(96),
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图 UI 背景图已生成。".to_string()),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn sync_generated_primary_level_name_as_default_work_title(
draft: &mut PuzzleResultDraft,
previous_work_title: &str,
@@ -1070,6 +1154,9 @@ fn select_puzzle_cover_image_tx(
level_name: target_level.level_name,
picture_description: target_level.picture_description,
picture_reference: target_level.picture_reference,
ui_background_prompt: target_level.ui_background_prompt,
ui_background_image_src: target_level.ui_background_image_src,
ui_background_image_object_key: target_level.ui_background_image_object_key,
background_music: target_level.background_music,
candidates: selected_level_draft.candidates,
selected_candidate_id: selected_level_draft.selected_candidate_id,
@@ -1079,7 +1166,7 @@ fn select_puzzle_cover_image_tx(
};
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros);
let next_stage = if build_result_preview(&draft, Some("百梦")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("陶泥儿")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -2241,7 +2328,7 @@ fn build_puzzle_agent_session_snapshot(
let messages = list_session_messages(ctx, &row.session_id);
let result_preview = draft
.as_ref()
.map(|value| build_result_preview(value, Some("百梦")));
.map(|value| build_result_preview(value, Some("陶泥儿")));
Ok(PuzzleAgentSessionSnapshot {
session_id: row.session_id.clone(),
@@ -2344,6 +2431,9 @@ fn build_profile_levels_from_row(
level_name: row.level_name.clone(),
picture_description: row.summary.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
@@ -2443,7 +2533,7 @@ fn upsert_puzzle_draft_work_profile(
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
"百梦".to_string(),
"陶泥儿".to_string(),
draft,
updated_at_micros,
)
@@ -3359,6 +3449,9 @@ mod tests {
.map(|level| level.picture_description.clone())
.unwrap_or_default(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: candidates.clone(),
selected_candidate_id: None,

View File

@@ -212,119 +212,18 @@ fn migrate_visual_novel_entry_from_old_open_default(ctx: &ReducerContext, now: T
}
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
vec![
build_creation_entry_type_seed(
"rpg",
"文字冒险",
"经典 RPG 体验",
"内测",
"/creation-type-references/rpg.webp",
false,
true,
10,
now,
),
build_creation_entry_type_seed(
"big-fish",
"摸鱼",
"轻量闯关玩法",
"可创建",
"/creation-type-references/big-fish.webp",
false,
true,
20,
now,
),
build_creation_entry_type_seed(
"puzzle",
"拼图",
"拼图关卡创作",
"可创建",
"/creation-type-references/puzzle.webp",
true,
true,
30,
now,
),
build_creation_entry_type_seed(
"match3d",
"抓大鹅",
"3D 消除关卡",
"可创建",
"/creation-type-references/match3d.webp",
true,
true,
40,
now,
),
build_creation_entry_type_seed(
"square-hole",
"方洞",
"形状投放挑战",
"可创建",
"/creation-type-references/square-hole.webp",
false,
true,
50,
now,
),
build_creation_entry_type_seed(
"visual-novel",
"视觉小说",
"分支叙事体验",
"敬请期待",
"/creation-type-references/visual-novel.webp",
true,
false,
60,
now,
),
build_creation_entry_type_seed(
"airp",
"AI RPG",
"原生角色扮演",
"即将开放",
"/creation-type-references/airp.webp",
true,
false,
70,
now,
),
build_creation_entry_type_seed(
"creative-agent",
"智能体创作",
"对话式创作实验",
"内测",
"/creation-type-references/creative-agent.webp",
false,
true,
80,
now,
),
]
}
#[allow(clippy::too_many_arguments)]
fn build_creation_entry_type_seed(
id: &str,
title: &str,
subtitle: &str,
badge: &str,
image_src: &str,
visible: bool,
open: bool,
sort_order: i32,
now: Timestamp,
) -> CreationEntryTypeConfig {
CreationEntryTypeConfig {
id: id.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
badge: badge.to_string(),
image_src: image_src.to_string(),
visible,
open,
sort_order,
updated_at: now,
}
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
.into_iter()
.map(|snapshot| CreationEntryTypeConfig {
id: snapshot.id,
title: snapshot.title,
subtitle: snapshot.subtitle,
badge: snapshot.badge,
image_src: snapshot.image_src,
visible: snapshot.visible,
open: snapshot.open,
sort_order: snapshot.sort_order,
updated_at: now,
})
.collect()
}

View File

@@ -336,6 +336,7 @@ pub struct ProfileMembership {
btree(columns = [user_id, created_at])
)
)]
#[derive(Clone)]
pub struct ProfileRechargeOrder {
#[primary_key]
pub(crate) order_id: String,
@@ -346,7 +347,10 @@ pub struct ProfileRechargeOrder {
pub(crate) amount_cents: u64,
pub(crate) status: RuntimeProfileRechargeOrderStatus,
pub(crate) payment_channel: String,
pub(crate) paid_at: Timestamp,
#[default(None::<Timestamp>)]
pub(crate) paid_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) provider_transaction_id: Option<String>,
pub(crate) created_at: Timestamp,
pub(crate) points_delta: i64,
pub(crate) membership_expires_at: Option<Timestamp>,
@@ -574,7 +578,7 @@ pub fn get_profile_task_center(
}
}
// 领奖记录与点流水在同一事务内写入,避免任务状态和钱包余额漂移。
// 领奖记录与点流水在同一事务内写入,避免任务状态和钱包余额漂移。
#[spacetimedb::procedure]
pub fn claim_profile_task_reward_and_return(
ctx: &mut ProcedureContext,
@@ -767,7 +771,6 @@ pub fn get_profile_recharge_center(
}
}
// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。
#[spacetimedb::procedure]
pub fn create_profile_recharge_order_and_return(
ctx: &mut ProcedureContext,
@@ -789,6 +792,27 @@ pub fn create_profile_recharge_order_and_return(
}
}
#[spacetimedb::procedure]
pub fn mark_profile_recharge_order_paid_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeOrderPaidInput,
) -> RuntimeProfileRechargeCenterProcedureResult {
match ctx.try_with_tx(|tx| mark_profile_recharge_order_paid_record(tx, input.clone())) {
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
ok: true,
record: Some(record),
order: Some(order),
error_message: None,
},
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
ok: false,
record: None,
order: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_profile_feedback_and_return(
ctx: &mut ProcedureContext,
@@ -828,7 +852,7 @@ pub fn get_profile_referral_invite_center(
}
}
// 填码绑定、每日邀请者奖励上限和双方点发放都在同一事务内完成。
// 填码绑定、每日邀请者奖励上限和双方点发放都在同一事务内完成。
#[spacetimedb::procedure]
pub fn redeem_profile_referral_invite_code(
ctx: &mut ProcedureContext,
@@ -1409,6 +1433,12 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str)
mod tests {
use super::*;
#[test]
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() {
assert!(should_skip_existing_tracking_event_id(true));
assert!(!should_skip_existing_tracking_event_id(false));
}
#[test]
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10;
@@ -2043,36 +2073,24 @@ fn create_profile_recharge_order_record(
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id)
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let (points_delta, membership_expires_at) = match product.kind {
RuntimeProfileRechargeProductKind::Points => {
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
let points_delta =
resolve_runtime_profile_points_recharge_delta(&product, has_recharged);
apply_profile_wallet_delta(
ctx,
&validated_input.user_id,
points_delta,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
&build_runtime_profile_recharge_wallet_ledger_id(
&validated_input.user_id,
validated_input.created_at_micros,
&product.product_id,
),
created_at,
)?;
(points_delta as i64, None)
}
RuntimeProfileRechargeProductKind::Membership => {
let expires_at = apply_profile_membership_purchase(
ctx,
&validated_input.user_id,
product.tier,
product.duration_days,
created_at,
);
(0, Some(expires_at))
}
let should_settle_immediately =
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately {
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
ctx,
&validated_input.user_id,
&product,
validated_input.created_at_micros,
created_at,
)?;
(
RuntimeProfileRechargeOrderStatus::Paid,
Some(created_at),
points_delta,
membership_expires_at,
)
} else {
(RuntimeProfileRechargeOrderStatus::Pending, None, 0, None)
};
let order = ProfileRechargeOrder {
@@ -2086,9 +2104,10 @@ fn create_profile_recharge_order_record(
product_title: product.title.clone(),
kind: product.kind,
amount_cents: product.price_cents,
status: RuntimeProfileRechargeOrderStatus::Paid,
status,
payment_channel: validated_input.payment_channel,
paid_at: created_at,
paid_at,
provider_transaction_id: None,
created_at,
points_delta,
membership_expires_at,
@@ -2103,6 +2122,106 @@ fn create_profile_recharge_order_record(
))
}
fn mark_profile_recharge_order_paid_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeOrderPaidInput,
) -> Result<
(
RuntimeProfileRechargeCenterSnapshot,
RuntimeProfileRechargeOrderSnapshot,
),
String,
> {
let validated_input = build_runtime_profile_recharge_order_paid_input(
input.order_id,
input.paid_at_micros,
input.provider_transaction_id,
)
.map_err(|error| error.to_string())?;
let mut order = ctx
.db
.profile_recharge_order()
.order_id()
.find(&validated_input.order_id)
.ok_or_else(|| "profile_recharge_order 不存在".to_string())?;
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
return Ok((
build_profile_recharge_center_snapshot(ctx, &order.user_id),
build_profile_recharge_order_snapshot_from_row(&order),
));
}
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
return Err("profile_recharge_order 当前状态不能确认支付".to_string());
}
let product = runtime_profile_recharge_product_by_id(&order.product_id)
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros);
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
ctx,
&order.user_id,
&product,
order.created_at.to_micros_since_unix_epoch(),
paid_at,
)?;
ctx.db
.profile_recharge_order()
.order_id()
.delete(&order.order_id);
order.status = RuntimeProfileRechargeOrderStatus::Paid;
order.paid_at = Some(paid_at);
order.provider_transaction_id = validated_input.provider_transaction_id;
order.points_delta = points_delta;
order.membership_expires_at = membership_expires_at;
ctx.db.profile_recharge_order().insert(order.clone());
Ok((
build_profile_recharge_center_snapshot(ctx, &order.user_id),
build_profile_recharge_order_snapshot_from_row(&order),
))
}
fn apply_profile_recharge_purchase(
ctx: &ReducerContext,
user_id: &str,
product: &RuntimeProfileRechargeProductSnapshot,
order_created_at_micros: i64,
paid_at: Timestamp,
) -> Result<(i64, Option<Timestamp>), String> {
match product.kind {
RuntimeProfileRechargeProductKind::Points => {
let has_recharged = has_profile_points_recharged(ctx, user_id);
let points_delta =
resolve_runtime_profile_points_recharge_delta(product, has_recharged);
apply_profile_wallet_delta(
ctx,
user_id,
points_delta,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
&build_runtime_profile_recharge_wallet_ledger_id(
user_id,
order_created_at_micros,
&product.product_id,
),
paid_at,
)?;
Ok((points_delta as i64, None))
}
RuntimeProfileRechargeProductKind::Membership => {
let expires_at = apply_profile_membership_purchase(
ctx,
user_id,
product.tier,
product.duration_days,
paid_at,
);
Ok((0, Some(expires_at)))
}
}
}
fn submit_profile_feedback_record(
ctx: &ReducerContext,
input: RuntimeProfileFeedbackSubmissionInput,
@@ -3223,6 +3342,10 @@ fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Res
)
}
fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool {
event_exists
}
fn record_tracking_event(
ctx: &ReducerContext,
input: RuntimeTrackingEventInput,
@@ -3242,6 +3365,15 @@ fn record_tracking_event(
.map_err(|error| error.to_string())?;
let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros);
let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros);
if should_skip_existing_tracking_event_id(
ctx.db
.tracking_event()
.event_id()
.find(&validated_input.event_id)
.is_some(),
) {
return Ok(());
}
// 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。
ensure_analytics_date_dimension_row(ctx, day_key)?;
ctx.db.tracking_event().insert(TrackingEvent {
@@ -3726,7 +3858,8 @@ fn build_profile_recharge_order_snapshot_from_row(
amount_cents: row.amount_cents,
status: row.status,
payment_channel: row.payment_channel.clone(),
paid_at_micros: row.paid_at.to_micros_since_unix_epoch(),
paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()),
provider_transaction_id: row.provider_transaction_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
points_delta: row.points_delta,
membership_expires_at_micros: row

View File

@@ -464,7 +464,7 @@ fn compile_square_hole_draft_tx(
work_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
source_session_id: input.session_id.clone(),
author_display_name: clean_string(&input.author_display_name, "百梦"),
author_display_name: clean_string(&input.author_display_name, "陶泥儿"),
game_name: draft.game_name.clone(),
theme_text: config.theme_text.clone(),
twist_rule: config.twist_rule.clone(),

View File

@@ -947,7 +947,7 @@ fn compile_visual_novel_work_profile_tx(
work_id: clean_optional(&input.work_id).unwrap_or_else(|| input.profile_id.clone()),
owner_user_id: input.owner_user_id.clone(),
source_session_id: input.session_id.clone(),
author_display_name: clean_string(&input.author_display_name, "百梦"),
author_display_name: clean_string(&input.author_display_name, "陶泥儿"),
work_title,
work_description,
tags_json: to_json_string(&normalize_tags(tags)),