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

@@ -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