1
This commit is contained in:
@@ -1135,6 +1135,25 @@ pub fn get_custom_world_agent_session(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn delete_custom_world_agent_session(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: CustomWorldAgentSessionGetInput,
|
||||
) -> CustomWorldWorksListResult {
|
||||
match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) {
|
||||
Ok(items) => CustomWorldWorksListResult {
|
||||
ok: true,
|
||||
items,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => CustomWorldWorksListResult {
|
||||
ok: false,
|
||||
items: Vec::new(),
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn submit_custom_world_agent_message(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -1392,6 +1411,101 @@ fn get_custom_world_agent_session_tx(
|
||||
Ok(build_custom_world_agent_session_snapshot(ctx, &session))
|
||||
}
|
||||
|
||||
fn delete_custom_world_agent_session_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldAgentSessionGetInput,
|
||||
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, String> {
|
||||
validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?;
|
||||
|
||||
let session = ctx
|
||||
.db
|
||||
.custom_world_agent_session()
|
||||
.session_id()
|
||||
.find(&input.session_id)
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||||
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
|
||||
|
||||
if session.stage == RpgAgentStage::Published {
|
||||
let published_profile = ctx
|
||||
.db
|
||||
.custom_world_profile()
|
||||
.iter()
|
||||
.find(|row| {
|
||||
row.owner_user_id == input.owner_user_id
|
||||
&& row.source_agent_session_id.as_deref() == Some(input.session_id.as_str())
|
||||
&& row.deleted_at.is_none()
|
||||
})
|
||||
.ok_or_else(|| "已发布 RPG 作品缺少关联 profile,无法删除".to_string())?;
|
||||
|
||||
// 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品”
|
||||
// 收敛为 profile 软删除,避免前端误入草稿删除接口时继续暴露 procedure 分叉。
|
||||
delete_custom_world_profile_record(
|
||||
ctx,
|
||||
CustomWorldProfileDeleteInput {
|
||||
profile_id: published_profile.profile_id,
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
|
||||
},
|
||||
)?;
|
||||
|
||||
return list_custom_world_work_snapshots(
|
||||
ctx,
|
||||
CustomWorldWorksListInput {
|
||||
owner_user_id: input.owner_user_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。
|
||||
ctx.db
|
||||
.custom_world_agent_session()
|
||||
.session_id()
|
||||
.delete(&session.session_id);
|
||||
for message in ctx
|
||||
.db
|
||||
.custom_world_agent_message()
|
||||
.iter()
|
||||
.filter(|row| row.session_id == input.session_id)
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.custom_world_agent_message()
|
||||
.message_id()
|
||||
.delete(&message.message_id);
|
||||
}
|
||||
for operation in ctx
|
||||
.db
|
||||
.custom_world_agent_operation()
|
||||
.iter()
|
||||
.filter(|row| row.session_id == input.session_id)
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.custom_world_agent_operation()
|
||||
.operation_id()
|
||||
.delete(&operation.operation_id);
|
||||
}
|
||||
for card in ctx
|
||||
.db
|
||||
.custom_world_draft_card()
|
||||
.iter()
|
||||
.filter(|row| row.session_id == input.session_id)
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
ctx.db
|
||||
.custom_world_draft_card()
|
||||
.card_id()
|
||||
.delete(&card.card_id);
|
||||
}
|
||||
|
||||
list_custom_world_work_snapshots(
|
||||
ctx,
|
||||
CustomWorldWorksListInput {
|
||||
owner_user_id: input.owner_user_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn submit_custom_world_agent_message_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldAgentMessageSubmitInput,
|
||||
@@ -1966,7 +2080,7 @@ pub fn list_custom_world_profiles(
|
||||
pub fn list_custom_world_gallery_entries(
|
||||
ctx: &mut ProcedureContext,
|
||||
) -> CustomWorldGalleryListResult {
|
||||
match ctx.try_with_tx(|tx| Ok::<_, String>(list_custom_world_gallery_snapshots(tx))) {
|
||||
match ctx.try_with_tx(|tx| list_custom_world_gallery_snapshots(tx)) {
|
||||
Ok(entries) => CustomWorldGalleryListResult {
|
||||
ok: true,
|
||||
entries,
|
||||
@@ -2668,7 +2782,9 @@ fn list_custom_world_profile_snapshots(
|
||||
|
||||
fn list_custom_world_gallery_snapshots(
|
||||
ctx: &ReducerContext,
|
||||
) -> Vec<CustomWorldGalleryEntrySnapshot> {
|
||||
) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
|
||||
sync_missing_custom_world_gallery_entries(ctx)?;
|
||||
|
||||
let mut entries = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
@@ -2683,7 +2799,7 @@ fn list_custom_world_gallery_snapshots(
|
||||
.then(right.updated_at_micros.cmp(&left.updated_at_micros))
|
||||
});
|
||||
|
||||
entries
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn get_custom_world_library_detail_record(
|
||||
@@ -3391,6 +3507,10 @@ fn execute_publish_world_action(
|
||||
.get("legacyResultProfile")
|
||||
.map(serialize_json_value)
|
||||
.transpose()?;
|
||||
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
|
||||
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
|
||||
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
|
||||
.unwrap_or_else(|| "创作者".to_string());
|
||||
let publish_result = publish_custom_world_world_record(
|
||||
ctx,
|
||||
CustomWorldPublishWorldInput {
|
||||
@@ -3398,17 +3518,11 @@ fn execute_publish_world_action(
|
||||
profile_id,
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
public_work_code: None,
|
||||
author_public_user_code: session
|
||||
.owner_user_id
|
||||
.trim_start_matches("user_")
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|sequence| format!("SY-{sequence:08}"))
|
||||
.unwrap_or_else(|| "SY-00000000".to_string()),
|
||||
author_public_user_code,
|
||||
draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?,
|
||||
legacy_result_profile_json,
|
||||
setting_text,
|
||||
author_display_name: "创作者".to_string(),
|
||||
author_display_name,
|
||||
published_at_micros: input.submitted_at_micros,
|
||||
},
|
||||
)?;
|
||||
@@ -4887,6 +5001,112 @@ fn sync_custom_world_gallery_entry_from_profile(
|
||||
Ok(build_custom_world_gallery_entry_snapshot(&inserted))
|
||||
}
|
||||
|
||||
fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> {
|
||||
let published_profiles = ctx
|
||||
.db
|
||||
.custom_world_profile()
|
||||
.iter()
|
||||
.filter(|profile| {
|
||||
profile.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& profile.deleted_at.is_none()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for profile in published_profiles {
|
||||
if profile.published_at.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let existing_gallery_entry = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
.profile_id()
|
||||
.find(&profile.profile_id)
|
||||
.filter(|entry| entry.owner_user_id == profile.owner_user_id);
|
||||
|
||||
if existing_gallery_entry.is_some()
|
||||
&& profile.public_work_code.is_some()
|
||||
&& profile.author_public_user_code.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let profile_with_public_fields = ensure_custom_world_profile_public_fields(ctx, &profile);
|
||||
sync_custom_world_gallery_entry_from_profile(ctx, &profile_with_public_fields)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_custom_world_profile_public_fields(
|
||||
ctx: &ReducerContext,
|
||||
profile: &CustomWorldProfile,
|
||||
) -> CustomWorldProfile {
|
||||
if profile.public_work_code.is_some() && profile.author_public_user_code.is_some() {
|
||||
return build_custom_world_profile_row_copy(profile);
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.custom_world_profile()
|
||||
.profile_id()
|
||||
.delete(&profile.profile_id);
|
||||
|
||||
let next_row = CustomWorldProfile {
|
||||
profile_id: profile.profile_id.clone(),
|
||||
owner_user_id: profile.owner_user_id.clone(),
|
||||
public_work_code: profile
|
||||
.public_work_code
|
||||
.clone()
|
||||
.or_else(|| Some(build_public_work_code_from_profile_id(&profile.profile_id))),
|
||||
author_public_user_code: profile.author_public_user_code.clone().or_else(|| {
|
||||
Some(build_public_user_code_from_owner_user_id(
|
||||
&profile.owner_user_id,
|
||||
))
|
||||
}),
|
||||
source_agent_session_id: profile.source_agent_session_id.clone(),
|
||||
publication_status: profile.publication_status,
|
||||
world_name: profile.world_name.clone(),
|
||||
subtitle: profile.subtitle.clone(),
|
||||
summary_text: profile.summary_text.clone(),
|
||||
theme_mode: profile.theme_mode,
|
||||
cover_image_src: profile.cover_image_src.clone(),
|
||||
profile_payload_json: profile.profile_payload_json.clone(),
|
||||
playable_npc_count: profile.playable_npc_count,
|
||||
landmark_count: profile.landmark_count,
|
||||
author_display_name: profile.author_display_name.clone(),
|
||||
published_at: profile.published_at,
|
||||
deleted_at: profile.deleted_at,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
};
|
||||
|
||||
ctx.db.custom_world_profile().insert(next_row)
|
||||
}
|
||||
|
||||
fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWorldProfile {
|
||||
CustomWorldProfile {
|
||||
profile_id: profile.profile_id.clone(),
|
||||
owner_user_id: profile.owner_user_id.clone(),
|
||||
public_work_code: profile.public_work_code.clone(),
|
||||
author_public_user_code: profile.author_public_user_code.clone(),
|
||||
source_agent_session_id: profile.source_agent_session_id.clone(),
|
||||
publication_status: profile.publication_status,
|
||||
world_name: profile.world_name.clone(),
|
||||
subtitle: profile.subtitle.clone(),
|
||||
summary_text: profile.summary_text.clone(),
|
||||
theme_mode: profile.theme_mode,
|
||||
cover_image_src: profile.cover_image_src.clone(),
|
||||
profile_payload_json: profile.profile_payload_json.clone(),
|
||||
playable_npc_count: profile.playable_npc_count,
|
||||
landmark_count: profile.landmark_count,
|
||||
author_display_name: profile.author_display_name.clone(),
|
||||
published_at: profile.published_at,
|
||||
deleted_at: profile.deleted_at,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
|
||||
CustomWorldProfileSnapshot {
|
||||
profile_id: row.profile_id.clone(),
|
||||
@@ -5079,6 +5299,15 @@ fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
|
||||
format!("CW-{normalized_digits}")
|
||||
}
|
||||
|
||||
fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String {
|
||||
owner_user_id
|
||||
.trim_start_matches("user_")
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|sequence| format!("SY-{sequence:08}"))
|
||||
.unwrap_or_else(|| "SY-00000000".to_string())
|
||||
}
|
||||
|
||||
fn normalize_public_work_code(input: &str) -> Option<String> {
|
||||
let normalized = input
|
||||
.trim()
|
||||
|
||||
@@ -55,6 +55,41 @@ pub struct ProfilePlayedWorld {
|
||||
pub(crate) last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_membership)]
|
||||
pub struct ProfileMembership {
|
||||
#[primary_key]
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) status: RuntimeProfileMembershipStatus,
|
||||
pub(crate) tier: RuntimeProfileMembershipTier,
|
||||
pub(crate) started_at: Timestamp,
|
||||
pub(crate) expires_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_recharge_order,
|
||||
index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])),
|
||||
index(
|
||||
accessor = by_profile_recharge_order_user_created_at,
|
||||
btree(columns = [user_id, created_at])
|
||||
)
|
||||
)]
|
||||
pub struct ProfileRechargeOrder {
|
||||
#[primary_key]
|
||||
pub(crate) order_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) product_id: String,
|
||||
pub(crate) product_title: String,
|
||||
pub(crate) kind: RuntimeProfileRechargeProductKind,
|
||||
pub(crate) amount_cents: u64,
|
||||
pub(crate) status: RuntimeProfileRechargeOrderStatus,
|
||||
pub(crate) payment_channel: String,
|
||||
pub(crate) paid_at: Timestamp,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) points_delta: i64,
|
||||
pub(crate) membership_expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_save_archive,
|
||||
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
|
||||
@@ -195,6 +230,50 @@ pub fn get_profile_play_stats(
|
||||
}
|
||||
}
|
||||
|
||||
// 账户充值中心只读快照,套餐和权益由后端返回,前端不保存业务价格表。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_profile_recharge_center(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeCenterGetInput,
|
||||
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
order: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
order: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_profile_recharge_order_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeOrderCreateInput,
|
||||
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||
match ctx.try_with_tx(|tx| create_profile_recharge_order_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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_profile_save_archive_rows(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileSaveArchiveListInput,
|
||||
@@ -775,6 +854,297 @@ fn get_profile_play_stats_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn get_profile_recharge_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeCenterGetInput,
|
||||
) -> Result<RuntimeProfileRechargeCenterSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_recharge_center_get_input(input.user_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(build_profile_recharge_center_snapshot(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
))
|
||||
}
|
||||
|
||||
fn create_profile_recharge_order_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeOrderCreateInput,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterSnapshot,
|
||||
RuntimeProfileRechargeOrderSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let validated_input = build_runtime_profile_recharge_order_create_input(
|
||||
input.user_id,
|
||||
input.product_id,
|
||||
input.payment_channel,
|
||||
input.created_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
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 bonus_points = if has_recharged {
|
||||
0
|
||||
} else {
|
||||
product.bonus_points
|
||||
};
|
||||
let points_delta = product.points_amount.saturating_add(bonus_points);
|
||||
apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
points_delta,
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
||||
&format!(
|
||||
"{}:{}:{}",
|
||||
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 order = ProfileRechargeOrder {
|
||||
order_id: format!(
|
||||
"recharge:{}:{}:{}",
|
||||
validated_input.user_id, validated_input.created_at_micros, product.product_id
|
||||
),
|
||||
user_id: validated_input.user_id.clone(),
|
||||
product_id: product.product_id.clone(),
|
||||
product_title: product.title.clone(),
|
||||
kind: product.kind,
|
||||
amount_cents: product.price_cents,
|
||||
status: RuntimeProfileRechargeOrderStatus::Paid,
|
||||
payment_channel: validated_input.payment_channel,
|
||||
paid_at: created_at,
|
||||
created_at,
|
||||
points_delta,
|
||||
membership_expires_at,
|
||||
};
|
||||
ctx.db.profile_recharge_order().insert(order);
|
||||
|
||||
let latest_order = latest_profile_recharge_order(ctx, &validated_input.user_id)
|
||||
.ok_or_else(|| "profile_recharge_order 写入后未能读取".to_string())?;
|
||||
Ok((
|
||||
build_profile_recharge_center_snapshot(ctx, &validated_input.user_id),
|
||||
build_profile_recharge_order_snapshot_from_row(&latest_order),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_profile_recharge_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> RuntimeProfileRechargeCenterSnapshot {
|
||||
let wallet_balance = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string())
|
||||
.map(|row| row.wallet_balance)
|
||||
.unwrap_or(0);
|
||||
|
||||
RuntimeProfileRechargeCenterSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance,
|
||||
membership: build_profile_membership_snapshot(ctx, user_id),
|
||||
point_products: runtime_profile_recharge_point_products(),
|
||||
membership_products: runtime_profile_recharge_membership_products(),
|
||||
benefits: runtime_profile_membership_benefits(),
|
||||
latest_order: latest_profile_recharge_order(ctx, user_id)
|
||||
.map(|row| build_profile_recharge_order_snapshot_from_row(&row)),
|
||||
has_points_recharged: has_profile_points_recharged(ctx, user_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_membership_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> RuntimeProfileMembershipSnapshot {
|
||||
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
|
||||
let membership = ctx
|
||||
.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
match membership {
|
||||
Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => {
|
||||
RuntimeProfileMembershipSnapshot {
|
||||
user_id: row.user_id,
|
||||
status: row.status,
|
||||
tier: row.tier,
|
||||
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
|
||||
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
|
||||
}
|
||||
}
|
||||
Some(row) => RuntimeProfileMembershipSnapshot {
|
||||
user_id: row.user_id,
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()),
|
||||
expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
|
||||
},
|
||||
None => RuntimeProfileMembershipSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
status: RuntimeProfileMembershipStatus::Normal,
|
||||
tier: RuntimeProfileMembershipTier::Normal,
|
||||
started_at_micros: None,
|
||||
expires_at_micros: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_profile_membership_purchase(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
tier: RuntimeProfileMembershipTier,
|
||||
duration_days: u32,
|
||||
purchased_at: Timestamp,
|
||||
) -> Timestamp {
|
||||
let current = ctx
|
||||
.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
let purchased_at_micros = purchased_at.to_micros_since_unix_epoch();
|
||||
let start_at_micros = current
|
||||
.as_ref()
|
||||
.map(|row| row.expires_at.to_micros_since_unix_epoch())
|
||||
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
|
||||
.unwrap_or(purchased_at_micros);
|
||||
let expires_at = Timestamp::from_micros_since_unix_epoch(
|
||||
start_at_micros.saturating_add(duration_days as i64 * 86_400_000_000),
|
||||
);
|
||||
let created_at = current
|
||||
.as_ref()
|
||||
.map(|row| row.started_at)
|
||||
.unwrap_or(purchased_at);
|
||||
|
||||
if let Some(existing) = current {
|
||||
ctx.db
|
||||
.profile_membership()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
}
|
||||
|
||||
ctx.db.profile_membership().insert(ProfileMembership {
|
||||
user_id: user_id.to_string(),
|
||||
status: RuntimeProfileMembershipStatus::Active,
|
||||
tier,
|
||||
started_at: created_at,
|
||||
expires_at,
|
||||
updated_at: purchased_at,
|
||||
});
|
||||
|
||||
expires_at
|
||||
}
|
||||
|
||||
fn apply_profile_wallet_delta(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
amount_delta: u64,
|
||||
source_type: RuntimeProfileWalletLedgerSourceType,
|
||||
ledger_id: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<u64, String> {
|
||||
let current = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
|
||||
let next_balance = previous_balance
|
||||
.checked_add(amount_delta)
|
||||
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?;
|
||||
let created_state_at = current
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or(created_at);
|
||||
|
||||
if let Some(existing) = current {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: next_balance,
|
||||
total_play_time_ms: existing.total_play_time_ms,
|
||||
created_at: existing.created_at,
|
||||
updated_at: created_at,
|
||||
});
|
||||
} else {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: next_balance,
|
||||
total_play_time_ms: 0,
|
||||
created_at: created_state_at,
|
||||
updated_at: created_at,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
|
||||
wallet_ledger_id: ledger_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
amount_delta: amount_delta as i64,
|
||||
balance_after: next_balance,
|
||||
source_type,
|
||||
created_at,
|
||||
});
|
||||
|
||||
Ok(next_balance)
|
||||
}
|
||||
|
||||
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
|
||||
ctx.db.profile_wallet_ledger().iter().any(|row| {
|
||||
row.user_id == user_id
|
||||
&& row.source_type == RuntimeProfileWalletLedgerSourceType::PointsRecharge
|
||||
})
|
||||
}
|
||||
|
||||
fn latest_profile_recharge_order(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
) -> Option<ProfileRechargeOrder> {
|
||||
let mut orders = ctx
|
||||
.db
|
||||
.profile_recharge_order()
|
||||
.iter()
|
||||
.filter(|row| row.user_id == user_id)
|
||||
.collect::<Vec<_>>();
|
||||
orders.sort_by(|left, right| {
|
||||
right
|
||||
.created_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.cmp(&left.created_at.to_micros_since_unix_epoch())
|
||||
.then_with(|| left.order_id.cmp(&right.order_id))
|
||||
});
|
||||
orders.into_iter().next()
|
||||
}
|
||||
|
||||
fn build_profile_wallet_ledger_snapshot_from_row(
|
||||
row: &ProfileWalletLedger,
|
||||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
@@ -788,6 +1158,27 @@ fn build_profile_wallet_ledger_snapshot_from_row(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_recharge_order_snapshot_from_row(
|
||||
row: &ProfileRechargeOrder,
|
||||
) -> RuntimeProfileRechargeOrderSnapshot {
|
||||
RuntimeProfileRechargeOrderSnapshot {
|
||||
order_id: row.order_id.clone(),
|
||||
user_id: row.user_id.clone(),
|
||||
product_id: row.product_id.clone(),
|
||||
product_title: row.product_title.clone(),
|
||||
kind: row.kind,
|
||||
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(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
points_delta: row.points_delta,
|
||||
membership_expires_at_micros: row
|
||||
.membership_expires_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_played_world_snapshot_from_row(
|
||||
row: &ProfilePlayedWorld,
|
||||
) -> RuntimeProfilePlayedWorldSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user