From 973eb1a6145c8e9f92524f73c0eca729a74bdcf5 Mon Sep 17 00:00:00 2001 From: "Entropy.Xu" Date: Sat, 16 May 2026 17:37:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(referrals):=20=E6=B7=BB=E5=8A=A0=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E8=BF=94=E5=88=A9=E5=92=8C=E6=B3=A8=E5=86=8C=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route/admin/operations_families.rs | 50 + .../src/control/route/public_support.rs | 16 + apps/aether-gateway/src/data/state/auth.rs | 127 +- apps/aether-gateway/src/data/state/mod.rs | 7 + .../src/data/state/referrals.rs | 2652 +++++++++++++++++ apps/aether-gateway/src/data/state/runtime.rs | 16 + .../handlers/admin/billing/payments/orders.rs | 14 + .../wallets/mutations/complete_refund.rs | 15 + apps/aether-gateway/src/handlers/admin/mod.rs | 1 + .../src/handlers/admin/referrals.rs | 278 ++ .../src/handlers/admin/routes.rs | 8 +- .../src/handlers/admin/users/groups.rs | 57 +- .../support/announcements/admin_routes.rs | 4 + .../public/support/announcements/shared.rs | 1 + .../support/announcements/user_routes.rs | 33 +- .../handlers/public/support/auth_helpers.rs | 27 + .../public/support/auth_registration.rs | 105 + .../handlers/public/support/payment/epay.rs | 16 +- .../public/support/payment/repository.rs | 40 +- .../src/handlers/public/support/user_me.rs | 3 + .../public/support/user_me_referral.rs | 69 + .../handlers/public/support/user_me_routes.rs | 14 +- .../src/state/runtime/announcements.rs | 12 + .../src/state/runtime/auth/user_lifecycle.rs | 8 + apps/aether-gateway/src/state/runtime/mod.rs | 1 + .../src/state/runtime/referrals.rs | 241 ++ .../src/tests/control/admin/users.rs | 115 + .../src/tests/frontdoor/public_support.rs | 204 ++ ...ferrals_privacy_required_announcements.sql | 59 + ...ferrals_privacy_required_announcements.sql | 57 + ...ferrals_privacy_required_announcements.sql | 63 + .../postgres/001_types_and_tables.sql | 62 + .../bootstrap/postgres/003_constraints.sql | 90 + .../schema/bootstrap/postgres/004_indexes.sql | 32 + .../bootstrap/postgres/005_foreign_keys.sql | 120 + .../src/lifecycle/bootstrap/postgres.rs | 2 +- .../src/lifecycle/migrate/tests.rs | 4 + .../src/repository/announcements/memory.rs | 51 + .../src/repository/announcements/mysql.rs | 42 +- .../src/repository/announcements/postgres.rs | 64 +- .../src/repository/announcements/sqlite.rs | 44 +- .../src/repository/announcements/types.rs | 12 + frontend/src/api/announcements.ts | 10 +- frontend/src/api/auth.ts | 11 + frontend/src/api/referrals.ts | 114 + .../features/auth/components/LoginDialog.vue | 28 +- .../auth/components/RegisterDialog.vue | 123 +- frontend/src/layouts/MainLayout.vue | 89 + frontend/src/router/index.ts | 22 + .../src/views/admin/ReferralManagement.vue | 439 +++ frontend/src/views/admin/SystemSettings.vue | 18 + .../system-settings/BasicConfigSection.vue | 216 ++ .../composables/useSystemConfig.ts | 100 + frontend/src/views/public/PrivacyPolicy.vue | 102 + frontend/src/views/user/Announcements.vue | 35 +- frontend/src/views/user/ReferralCenter.vue | 155 + 56 files changed, 6246 insertions(+), 52 deletions(-) create mode 100644 apps/aether-gateway/src/data/state/referrals.rs create mode 100644 apps/aether-gateway/src/handlers/admin/referrals.rs create mode 100644 apps/aether-gateway/src/handlers/public/support/user_me_referral.rs create mode 100644 apps/aether-gateway/src/state/runtime/referrals.rs create mode 100644 crates/aether-data/migrations/mysql/20260516000000_add_referrals_privacy_required_announcements.sql create mode 100644 crates/aether-data/migrations/postgres/20260516000000_add_referrals_privacy_required_announcements.sql create mode 100644 crates/aether-data/migrations/sqlite/20260516000000_add_referrals_privacy_required_announcements.sql create mode 100644 frontend/src/api/referrals.ts create mode 100644 frontend/src/views/admin/ReferralManagement.vue create mode 100644 frontend/src/views/public/PrivacyPolicy.vue create mode 100644 frontend/src/views/user/ReferralCenter.vue diff --git a/apps/aether-gateway/src/control/route/admin/operations_families.rs b/apps/aether-gateway/src/control/route/admin/operations_families.rs index 5046f9c76..7ceca03cd 100644 --- a/apps/aether-gateway/src/control/route/admin/operations_families.rs +++ b/apps/aether-gateway/src/control/route/admin/operations_families.rs @@ -8,6 +8,56 @@ pub(super) fn classify_admin_operations_family_route( normalized_path_no_trailing: &str, ) -> Option { if method == http::Method::GET + && matches!( + normalized_path, + "/api/admin/referrals" | "/api/admin/referrals/" + ) + { + Some(classified( + "admin_proxy", + "referrals_manage", + "list_referrals", + "admin:billing", + false, + )) + } else if method == http::Method::GET + && matches!( + normalized_path, + "/api/admin/referral-rewards" | "/api/admin/referral-rewards/" + ) + { + Some(classified( + "admin_proxy", + "referrals_manage", + "list_referral_rewards", + "admin:billing", + false, + )) + } else if method == http::Method::POST + && normalized_path.starts_with("/api/admin/referral-rewards/") + && normalized_path.ends_with("/retry") + && normalized_path.matches('/').count() == 5 + { + Some(classified( + "admin_proxy", + "referrals_manage", + "retry_referral_reward", + "admin:billing", + false, + )) + } else if method == http::Method::POST + && normalized_path.starts_with("/api/admin/referral-rewards/") + && normalized_path.ends_with("/void") + && normalized_path.matches('/').count() == 5 + { + Some(classified( + "admin_proxy", + "referrals_manage", + "void_referral_reward", + "admin:billing", + false, + )) + } else if method == http::Method::GET && matches!( normalized_path, "/api/admin/provider-ops/architectures" | "/api/admin/provider-ops/architectures/" diff --git a/apps/aether-gateway/src/control/route/public_support.rs b/apps/aether-gateway/src/control/route/public_support.rs index 395960015..b35e37077 100644 --- a/apps/aether-gateway/src/control/route/public_support.rs +++ b/apps/aether-gateway/src/control/route/public_support.rs @@ -279,6 +279,20 @@ pub(super) fn classify_public_support_route( "user:announcements", false, )) + } else if method == http::Method::GET + && matches!( + normalized_path, + "/api/announcements/users/me/required-unread" + | "/api/announcements/users/me/required-unread/" + ) + { + Some(classified( + "public_support", + "announcement_user", + "required_unread", + "user:announcements", + false, + )) } else if method == http::Method::POST && matches!( normalized_path, @@ -462,6 +476,7 @@ pub(super) fn classify_public_support_route( | "/api/users/me/available-models" | "/api/users/me/endpoint-status" | "/api/users/me/preferences" + | "/api/users/me/referral" | "/api/users/me/model-capabilities" ) { @@ -477,6 +492,7 @@ pub(super) fn classify_public_support_route( "/api/users/me/available-models" => "available_models", "/api/users/me/endpoint-status" => "endpoint_status", "/api/users/me/preferences" => "preferences", + "/api/users/me/referral" => "referral", "/api/users/me/model-capabilities" => "model_capabilities", _ => "detail", }; diff --git a/apps/aether-gateway/src/data/state/auth.rs b/apps/aether-gateway/src/data/state/auth.rs index e4d67cf70..37f8cddf1 100644 --- a/apps/aether-gateway/src/data/state/auth.rs +++ b/apps/aether-gateway/src/data/state/auth.rs @@ -1925,14 +1925,38 @@ fn resolve_effective_list_policy( &aether_data::repository::users::StoredUserGroup, ) -> (&str, Option>), ) -> Option> { - let group_policy = groups.iter().fold(None, |effective, group| { - let (mode, values) = group_field(group); - intersect_list_policies(effective, list_restriction_from_mode(mode, values)) - }); + let group_policy = union_group_list_policies(groups, group_field); let user_policy = list_restriction_from_mode(user_mode, user_values); intersect_list_policies(group_policy, user_policy) } +fn union_group_list_policies( + groups: &[aether_data::repository::users::StoredUserGroup], + group_field: impl Fn( + &aether_data::repository::users::StoredUserGroup, + ) -> (&str, Option>), +) -> Option> { + let mut saw_restrictive_group = false; + let mut values = std::collections::BTreeSet::new(); + + for group in groups { + let (mode, group_values) = group_field(group); + match mode { + "unrestricted" => return None, + "specific" => { + saw_restrictive_group = true; + values.extend(group_values.unwrap_or_default()); + } + "deny_all" => { + saw_restrictive_group = true; + } + _ => {} + } + } + + saw_restrictive_group.then(|| values.into_iter().collect()) +} + fn list_restriction_from_mode(mode: &str, values: Option>) -> Option> { match mode { "specific" => Some(values.unwrap_or_default()), @@ -2148,7 +2172,7 @@ mod tests { } #[test] - fn list_policy_intersects_group_and_user_restrictions() { + fn list_policy_intersects_unrestricted_group_union_with_user_restriction() { let groups = vec![ sample_group("default", 0, None, "unrestricted", None, "system"), sample_group( @@ -2168,11 +2192,14 @@ mod tests { |group| (&group.allowed_models_mode, group.allowed_models.clone()), ); - assert_eq!(policy, Some(vec!["gpt-4.1".to_string()])); + assert_eq!( + policy, + Some(vec!["gpt-4.1".to_string(), "gemini-2.5-pro".to_string()]) + ); } #[test] - fn list_policy_intersects_multiple_group_restrictions() { + fn list_policy_unions_multiple_group_restrictions_legacy_case() { let groups = vec![ sample_group( "team-a", @@ -2196,7 +2223,91 @@ mod tests { (&group.allowed_models_mode, group.allowed_models.clone()) }); - assert_eq!(policy, Some(vec!["gpt-4.1".to_string()])); + assert_eq!( + policy, + Some(vec![ + "gemini-2.5-pro".to_string(), + "gpt-4.1".to_string(), + "gpt-5".to_string() + ]) + ); + } + + #[test] + fn list_policy_unions_multiple_group_restrictions() { + let groups = vec![ + sample_group( + "team-a", + 10, + Some(vec!["gpt-5", "gpt-4.1"]), + "specific", + None, + "system", + ), + sample_group( + "team-b", + 20, + Some(vec!["gpt-4.1", "gemini-2.5-pro"]), + "specific", + None, + "system", + ), + ]; + + let policy = resolve_effective_list_policy(None, "unrestricted", &groups, |group| { + (&group.allowed_models_mode, group.allowed_models.clone()) + }); + + assert_eq!( + policy, + Some(vec![ + "gemini-2.5-pro".to_string(), + "gpt-4.1".to_string(), + "gpt-5".to_string() + ]) + ); + } + + #[test] + fn unrestricted_group_makes_group_policy_unrestricted() { + let groups = vec![ + sample_group( + "restricted", + 10, + Some(vec!["gpt-5"]), + "specific", + None, + "system", + ), + sample_group("unrestricted", 20, None, "unrestricted", None, "system"), + ]; + + let policy = resolve_effective_list_policy(None, "unrestricted", &groups, |group| { + (&group.allowed_models_mode, group.allowed_models.clone()) + }); + + assert_eq!(policy, None); + } + + #[test] + fn deny_all_group_does_not_remove_other_group_grants() { + let groups = vec![ + sample_group("deny", 10, None, "deny_all", None, "system"), + sample_group( + "restricted", + 20, + Some(vec!["gpt-5"]), + "specific", + None, + "system", + ), + ]; + + let policy = resolve_effective_list_policy(None, "unrestricted", &groups, |group| { + (&group.allowed_models_mode, group.allowed_models.clone()) + }); + + assert_eq!(policy, Some(vec!["gpt-5".to_string()])); } #[test] diff --git a/apps/aether-gateway/src/data/state/mod.rs b/apps/aether-gateway/src/data/state/mod.rs index 21cf9ea8d..2512c77b5 100644 --- a/apps/aether-gateway/src/data/state/mod.rs +++ b/apps/aether-gateway/src/data/state/mod.rs @@ -138,6 +138,12 @@ use aether_data_contracts::repository::video_tasks::{ }; use aether_runtime_state::RuntimeQueueStore; +pub(crate) use self::referrals::{ + ReferralAdminStats, ReferralMutationStatus, ReferralRelationshipListQuery, + ReferralRelationshipRecord, ReferralRewardConfig, ReferralRewardListQuery, + ReferralRewardRecord, ReferralUserDashboard, +}; + #[derive(Clone, Default)] pub(crate) struct GatewayDataState { config: GatewayDataConfig, @@ -302,6 +308,7 @@ mod core; mod integrations; mod models; mod pool_scores; +mod referrals; mod runtime; #[cfg(test)] mod testing; diff --git a/apps/aether-gateway/src/data/state/referrals.rs b/apps/aether-gateway/src/data/state/referrals.rs new file mode 100644 index 000000000..7aea9bee3 --- /dev/null +++ b/apps/aether-gateway/src/data/state/referrals.rs @@ -0,0 +1,2652 @@ +use aether_data::DataLayerError; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::collections::HashSet; + +use super::GatewayDataState; + +const REFERRAL_FETCH_LIMIT: usize = 5_000; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ReferralUserDashboard { + pub(crate) invite_code: String, + pub(crate) total_invites: u64, + pub(crate) effective_invites: u64, + pub(crate) paid_reward_usd: f64, + pub(crate) pending_reward_usd: f64, + pub(crate) reversed_reward_usd: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ReferralRelationshipRecord { + pub(crate) id: String, + pub(crate) inviter_user_id: String, + pub(crate) inviter_username: Option, + pub(crate) invitee_user_id: String, + pub(crate) invitee_username: Option, + pub(crate) invite_code_snapshot: String, + pub(crate) first_paid_order_id: Option, + pub(crate) first_paid_at_unix_secs: Option, + pub(crate) source: Option, + pub(crate) created_at_unix_secs: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ReferralRewardRecord { + pub(crate) id: String, + pub(crate) referral_id: String, + pub(crate) inviter_user_id: String, + pub(crate) invitee_user_id: String, + pub(crate) reward_type: String, + pub(crate) source_order_id: Option, + pub(crate) trigger_point: String, + pub(crate) amount_usd: f64, + pub(crate) status: String, + pub(crate) wallet_transaction_id: Option, + pub(crate) idempotency_key: String, + pub(crate) reversed_amount_usd: f64, + pub(crate) pending_reversal_amount_usd: f64, + pub(crate) admin_operator_id: Option, + pub(crate) admin_note: Option, + pub(crate) created_at_unix_secs: u64, + pub(crate) updated_at_unix_secs: u64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub(crate) struct ReferralAdminStats { + pub(crate) total_invites: u64, + pub(crate) effective_invites: u64, + pub(crate) paid_reward_usd: f64, + pub(crate) pending_reward_usd: f64, + pub(crate) reversed_reward_usd: f64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct ReferralRelationshipListQuery { + pub(crate) inviter: Option, + pub(crate) invitee: Option, + pub(crate) invite_code: Option, + pub(crate) first_paid: Option, + pub(crate) limit: usize, + pub(crate) offset: usize, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct ReferralRewardListQuery { + pub(crate) order_id: Option, + pub(crate) reward_type: Option, + pub(crate) status: Option, + pub(crate) limit: usize, + pub(crate) offset: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct ReferralRewardConfig { + pub(crate) percent_enabled: bool, + pub(crate) percent_rate: f64, + pub(crate) headcount_enabled: bool, + pub(crate) headcount_amount_usd: f64, + pub(crate) headcount_trigger: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReferralMutationStatus { + Applied, + NotFound, + Invalid, + Unavailable, +} + +#[derive(Debug, Clone)] +struct ReferralPaymentOrderContext { + id: String, + user_id: String, + amount_usd: f64, + payment_method: String, + status: String, + order_kind: String, +} + +#[derive(Debug, Clone)] +struct ReferralPaymentOrderRefundContext { + amount_usd: f64, + refunded_amount_usd: f64, +} + +#[derive(Debug, Clone)] +struct ReferralCreditTarget { + id: String, + wallet_id: String, + inviter_user_id: String, + invitee_user_id: String, + amount_usd: f64, + reward_type: String, + trigger_point: String, +} + +macro_rules! row_string { + ($row:expr, $col:expr) => { + $row.try_get::($col) + .map_err(DataLayerError::sql)? + }; +} + +macro_rules! row_optional_string { + ($row:expr, $col:expr) => { + $row.try_get::, _>($col) + .map_err(DataLayerError::sql)? + }; +} + +macro_rules! row_f64 { + ($row:expr, $col:expr) => { + $row.try_get::($col).map_err(DataLayerError::sql)? + }; +} + +macro_rules! relationship_from_row { + ($row:expr) => {{ + let source_text = row_optional_string!($row, "source_json"); + Ok(ReferralRelationshipRecord { + id: row_string!($row, "id"), + inviter_user_id: row_string!($row, "inviter_user_id"), + inviter_username: row_optional_string!($row, "inviter_username"), + invitee_user_id: row_string!($row, "invitee_user_id"), + invitee_username: row_optional_string!($row, "invitee_username"), + invite_code_snapshot: row_string!($row, "invite_code_snapshot"), + first_paid_order_id: row_optional_string!($row, "first_paid_order_id"), + first_paid_at_unix_secs: row_optional_unix_secs($row, "first_paid_at_unix_secs")?, + source: parse_optional_json(source_text)?, + created_at_unix_secs: row_unix_secs($row, "created_at_unix_secs")?, + }) + }}; +} + +macro_rules! reward_from_row { + ($row:expr) => {{ + Ok(ReferralRewardRecord { + id: row_string!($row, "id"), + referral_id: row_string!($row, "referral_id"), + inviter_user_id: row_string!($row, "inviter_user_id"), + invitee_user_id: row_string!($row, "invitee_user_id"), + reward_type: row_string!($row, "reward_type"), + source_order_id: row_optional_string!($row, "source_order_id"), + trigger_point: row_string!($row, "trigger_point"), + amount_usd: row_f64!($row, "amount_usd"), + status: row_string!($row, "status"), + wallet_transaction_id: row_optional_string!($row, "wallet_transaction_id"), + idempotency_key: row_string!($row, "idempotency_key"), + reversed_amount_usd: row_f64!($row, "reversed_amount_usd"), + pending_reversal_amount_usd: row_f64!($row, "pending_reversal_amount_usd"), + admin_operator_id: row_optional_string!($row, "admin_operator_id"), + admin_note: row_optional_string!($row, "admin_note"), + created_at_unix_secs: row_unix_secs($row, "created_at_unix_secs")?, + updated_at_unix_secs: row_unix_secs($row, "updated_at_unix_secs")?, + }) + }}; +} + +fn now_unix_secs() -> u64 { + chrono::Utc::now().timestamp().max(0) as u64 +} + +fn now_unix_ms() -> u64 { + chrono::Utc::now().timestamp_millis().max(0) as u64 +} + +fn row_unix_secs(row: &R, column: &str) -> Result +where + R: Row, + for<'c> &'c str: sqlx::ColumnIndex, + for<'r> i64: sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + let value = row.try_get::(column).map_err(DataLayerError::sql)?; + Ok(value.max(0) as u64) +} + +fn row_optional_unix_secs(row: &R, column: &str) -> Result, DataLayerError> +where + R: Row, + for<'c> &'c str: sqlx::ColumnIndex, + for<'r> Option: sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + let value = row + .try_get::, _>(column) + .map_err(DataLayerError::sql)?; + Ok(value.map(|value| value.max(0) as u64)) +} + +fn parse_optional_json(value: Option) -> Result, DataLayerError> { + value + .filter(|value| !value.trim().is_empty()) + .map(|value| serde_json::from_str(&value).map_err(DataLayerError::sql)) + .transpose() +} + +fn generate_invite_code() -> String { + format!( + "AE{}", + &uuid::Uuid::new_v4().simple().to_string()[..10].to_ascii_uppercase() + ) +} + +fn referral_text_matches(value: Option<&str>, needle: Option<&str>) -> bool { + let Some(needle) = needle.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + let Some(value) = value else { + return false; + }; + value + .to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()) +} + +fn referral_list_window(items: &[T], limit: usize, offset: usize) -> Vec { + let limit = limit.clamp(1, 200); + items.iter().skip(offset).take(limit).cloned().collect() +} + +fn referral_admin_stats( + relationships: &[ReferralRelationshipRecord], + rewards: &[ReferralRewardRecord], +) -> ReferralAdminStats { + ReferralAdminStats { + total_invites: relationships.len() as u64, + effective_invites: relationships + .iter() + .filter(|item| item.first_paid_order_id.is_some()) + .count() as u64, + paid_reward_usd: rewards + .iter() + .filter(|item| item.status == "applied") + .map(|item| item.amount_usd) + .sum(), + pending_reward_usd: rewards + .iter() + .filter(|item| matches!(item.status.as_str(), "pending" | "failed")) + .map(|item| item.amount_usd) + .sum(), + reversed_reward_usd: rewards.iter().map(|item| item.reversed_amount_usd).sum(), + } +} + +fn normalize_referral_code(value: &str) -> Option { + let value = value.trim().to_ascii_uppercase(); + (!value.is_empty() && value.len() <= 64).then_some(value) +} + +fn reward_description(target: &ReferralCreditTarget) -> String { + match target.reward_type.as_str() { + "percent" => "邀请充值比例返利".to_string(), + "headcount" => "邀请人头返利".to_string(), + _ => "邀请返利".to_string(), + } +} + +fn referral_retry_allowed(status: &str) -> bool { + status == "failed" +} + +fn referral_void_allowed(status: &str) -> bool { + matches!(status, "pending" | "failed") +} + +fn referral_reversal_delta( + reward_amount_usd: f64, + order_amount_usd: f64, + refunded_amount_usd: f64, + reversed_amount_usd: f64, + pending_reversal_amount_usd: f64, +) -> f64 { + let target_reversal = + referral_reversal_target(reward_amount_usd, order_amount_usd, refunded_amount_usd); + (target_reversal - reversed_amount_usd - pending_reversal_amount_usd).max(0.0) +} + +fn referral_reversal_target( + reward_amount_usd: f64, + order_amount_usd: f64, + refunded_amount_usd: f64, +) -> f64 { + if reward_amount_usd <= 0.0 || order_amount_usd <= 0.0 || refunded_amount_usd <= 0.0 { + return 0.0; + } + reward_amount_usd * (refunded_amount_usd / order_amount_usd).clamp(0.0, 1.0) +} + +impl GatewayDataState { + pub(crate) fn has_referral_data_backend(&self) -> bool { + self.backends.is_some() + } + + pub(crate) async fn record_user_privacy_policy_acceptance( + &self, + user_id: &str, + version: &str, + ) -> Result { + let Some(backends) = self.backends.as_ref() else { + return Ok(false); + }; + if let Some(backend) = backends.postgres() { + let affected = sqlx::query( + r#" +UPDATE users +SET privacy_policy_accepted_version = $2, + privacy_policy_accepted_at = NOW() +WHERE id = $1 +"#, + ) + .bind(user_id) + .bind(version) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.mysql() { + let affected = sqlx::query( + r#" +UPDATE users +SET privacy_policy_accepted_version = ?, + privacy_policy_accepted_at = ? +WHERE id = ? +"#, + ) + .bind(version) + .bind(now_unix_secs() as i64) + .bind(user_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.sqlite() { + let affected = sqlx::query( + r#" +UPDATE users +SET privacy_policy_accepted_version = ?, + privacy_policy_accepted_at = ? +WHERE id = ? +"#, + ) + .bind(version) + .bind(now_unix_secs() as i64) + .bind(user_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + Ok(false) + } + + pub(crate) async fn referral_dashboard( + &self, + user_id: &str, + ) -> Result, DataLayerError> { + let Some(invite_code) = self.ensure_referral_invite_code(user_id).await? else { + return Ok(None); + }; + let relationships = self + .list_referral_relationships_raw(Some(user_id), None) + .await?; + let rewards = self.list_referral_rewards_raw(Some(user_id)).await?; + let stats = referral_admin_stats(&relationships, &rewards); + Ok(Some(ReferralUserDashboard { + invite_code, + total_invites: stats.total_invites, + effective_invites: stats.effective_invites, + paid_reward_usd: stats.paid_reward_usd, + pending_reward_usd: stats.pending_reward_usd, + reversed_reward_usd: stats.reversed_reward_usd, + })) + } + + pub(crate) async fn list_admin_referral_relationships( + &self, + query: ReferralRelationshipListQuery, + ) -> Result, u64, ReferralAdminStats)>, DataLayerError> + { + if self.backends.is_none() { + return Ok(None); + } + let all_relationships = self.list_referral_relationships_raw(None, None).await?; + let all_rewards = self.list_referral_rewards_raw(None).await?; + let filtered = all_relationships + .into_iter() + .filter(|item| { + referral_text_matches(item.inviter_username.as_deref(), query.inviter.as_deref()) + || referral_text_matches( + Some(item.inviter_user_id.as_str()), + query.inviter.as_deref(), + ) + }) + .filter(|item| { + referral_text_matches(item.invitee_username.as_deref(), query.invitee.as_deref()) + || referral_text_matches( + Some(item.invitee_user_id.as_str()), + query.invitee.as_deref(), + ) + }) + .filter(|item| { + referral_text_matches( + Some(item.invite_code_snapshot.as_str()), + query.invite_code.as_deref(), + ) + }) + .filter(|item| { + query + .first_paid + .map(|expected| item.first_paid_order_id.is_some() == expected) + .unwrap_or(true) + }) + .collect::>(); + let total = filtered.len() as u64; + let filtered_referral_ids = filtered + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + let filtered_rewards = all_rewards + .into_iter() + .filter(|item| filtered_referral_ids.contains(item.referral_id.as_str())) + .collect::>(); + let stats = referral_admin_stats(&filtered, &filtered_rewards); + Ok(Some(( + referral_list_window(&filtered, query.limit, query.offset), + total, + stats, + ))) + } + + pub(crate) async fn list_admin_referral_rewards( + &self, + query: ReferralRewardListQuery, + ) -> Result, u64, ReferralAdminStats)>, DataLayerError> { + if self.backends.is_none() { + return Ok(None); + } + let relationships = self.list_referral_relationships_raw(None, None).await?; + let rewards = self.list_referral_rewards_raw(None).await?; + let filtered = rewards + .iter() + .filter(|item| { + referral_text_matches(item.source_order_id.as_deref(), query.order_id.as_deref()) + }) + .filter(|item| { + referral_text_matches( + Some(item.reward_type.as_str()), + query.reward_type.as_deref(), + ) + }) + .filter(|item| { + referral_text_matches(Some(item.status.as_str()), query.status.as_deref()) + }) + .cloned() + .collect::>(); + let total = filtered.len() as u64; + let filtered_referral_ids = filtered + .iter() + .map(|item| item.referral_id.as_str()) + .collect::>(); + let filtered_relationships = relationships + .into_iter() + .filter(|item| filtered_referral_ids.contains(item.id.as_str())) + .collect::>(); + let stats = referral_admin_stats(&filtered_relationships, &filtered); + Ok(Some(( + referral_list_window(&filtered, query.limit, query.offset), + total, + stats, + ))) + } + + pub(crate) async fn bind_referral_invite_code( + &self, + invitee_user_id: &str, + invite_code: Option<&str>, + source: Option, + ) -> Result, DataLayerError> { + let Some(code) = invite_code.and_then(normalize_referral_code) else { + return Ok(None); + }; + let Some(inviter_user_id) = self.find_referral_inviter_by_code(&code).await? else { + return Err(DataLayerError::InvalidInput("邀请码无效".to_string())); + }; + if inviter_user_id == invitee_user_id { + return Err(DataLayerError::InvalidInput( + "不能使用自己的邀请码注册".to_string(), + )); + } + let referral_id = uuid::Uuid::new_v4().to_string(); + let source_json = source.map(|value| value.to_string()); + let inserted = self + .insert_referral_relationship( + &referral_id, + &inviter_user_id, + invitee_user_id, + &code, + source_json.as_deref(), + ) + .await?; + if !inserted { + return Ok(None); + } + self.find_referral_relationship(&referral_id).await + } + + pub(crate) async fn apply_registration_referral_reward( + &self, + invitee_user_id: &str, + amount_usd: f64, + trigger_point: &str, + ) -> Result, DataLayerError> { + if amount_usd <= 0.0 { + return Ok(Vec::new()); + } + let Some(relationship) = self + .find_referral_relationship_by_invitee(invitee_user_id) + .await? + else { + return Ok(Vec::new()); + }; + let idempotency_key = format!("referral:{}:headcount:{trigger_point}", relationship.id); + self.insert_referral_reward( + &relationship, + "headcount", + None, + trigger_point, + amount_usd, + &idempotency_key, + ) + .await?; + self.credit_pending_referral_rewards(&[idempotency_key], None, None) + .await + } + + pub(crate) async fn apply_paid_order_referral_rewards( + &self, + order_id: &str, + config: ReferralRewardConfig, + ) -> Result, DataLayerError> { + if self.backends.is_none() { + return Ok(Vec::new()); + } + if !config.percent_enabled && !config.headcount_enabled { + return Ok(Vec::new()); + } + let Some(context) = self.find_referral_payment_order_context(order_id).await? else { + return Ok(Vec::new()); + }; + if context.status != "credited" { + return Ok(Vec::new()); + } + if !matches!( + context.order_kind.as_str(), + "wallet_recharge" | "plan_purchase" + ) { + return Ok(Vec::new()); + } + if matches!( + context.payment_method.as_str(), + "manual" | "admin_manual" | "redeem_code" | "gift" + ) { + return Ok(Vec::new()); + } + let Some(relationship) = self + .find_referral_relationship_by_invitee(&context.user_id) + .await? + else { + return Ok(Vec::new()); + }; + let marked_first_paid = self + .mark_referral_first_paid_order(&relationship.id, &context.id) + .await?; + + let mut idempotency_keys = Vec::new(); + if config.percent_enabled && config.percent_rate > 0.0 { + let amount_usd = (context.amount_usd * config.percent_rate / 100.0).max(0.0); + if amount_usd > 0.0 { + let idempotency_key = + format!("referral:{}:percent:{}", relationship.id, context.id); + self.insert_referral_reward( + &relationship, + "percent", + Some(&context.id), + "paid_order", + amount_usd, + &idempotency_key, + ) + .await?; + idempotency_keys.push(idempotency_key); + } + } + if config.headcount_enabled + && config.headcount_amount_usd > 0.0 + && config.headcount_trigger == "first_paid_order" + && marked_first_paid + { + let idempotency_key = + format!("referral:{}:headcount:first_paid_order", relationship.id); + self.insert_referral_reward( + &relationship, + "headcount", + Some(&context.id), + "first_paid_order", + config.headcount_amount_usd, + &idempotency_key, + ) + .await?; + idempotency_keys.push(idempotency_key); + } + + if idempotency_keys.is_empty() { + return Ok(Vec::new()); + } + self.credit_pending_referral_rewards(&idempotency_keys, None, None) + .await + } + + pub(crate) async fn retry_referral_reward( + &self, + reward_id: &str, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result, DataLayerError> { + let Some(reward) = self.find_referral_reward(reward_id).await? else { + return Ok(None); + }; + if !referral_retry_allowed(&reward.status) { + return Err(DataLayerError::InvalidInput( + "仅失败返利可以补发".to_string(), + )); + } + let rewards = self + .credit_pending_referral_rewards(&[reward.idempotency_key], operator_id, note) + .await?; + Ok(rewards.into_iter().next()) + } + + pub(crate) async fn void_referral_reward( + &self, + reward_id: &str, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result, DataLayerError> { + let Some(reward) = self.find_referral_reward(reward_id).await? else { + return Ok(None); + }; + if !referral_void_allowed(&reward.status) { + return Err(DataLayerError::InvalidInput( + "仅待发或失败返利可以作废".to_string(), + )); + } + self.update_referral_reward_status(reward_id, "voided", operator_id, note) + .await?; + self.find_referral_reward(reward_id).await + } + + pub(crate) async fn reverse_referral_rewards_for_order( + &self, + order_id: &str, + amount_usd: f64, + ) -> Result, DataLayerError> { + if amount_usd <= 0.0 { + return Ok(Vec::new()); + } + let Some(refund_context) = self + .find_referral_payment_order_refund_context(order_id) + .await? + else { + return Ok(Vec::new()); + }; + let rewards = self + .find_applied_referral_rewards_by_order(order_id) + .await?; + let mut reversed = Vec::new(); + for reward in rewards { + let reversal_amount = referral_reversal_delta( + reward.amount_usd, + refund_context.amount_usd, + refund_context.refunded_amount_usd, + reward.reversed_amount_usd, + reward.pending_reversal_amount_usd, + ); + if reversal_amount <= 0.0 { + continue; + } + let target_reversal = referral_reversal_target( + reward.amount_usd, + refund_context.amount_usd, + refund_context.refunded_amount_usd, + ); + self.apply_referral_reward_reversal(&reward, target_reversal) + .await?; + if let Some(updated) = self.find_referral_reward(&reward.id).await? { + reversed.push(updated); + } + } + Ok(reversed) + } +} + +impl GatewayDataState { + async fn ensure_referral_invite_code( + &self, + user_id: &str, + ) -> Result, DataLayerError> { + if let Some(existing) = self.find_referral_invite_code(user_id).await? { + return Ok(Some(existing)); + } + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + for _ in 0..5 { + let code = generate_invite_code(); + let inserted = if let Some(backend) = backends.postgres() { + sqlx::query( + r#" +INSERT INTO user_invite_codes (user_id, invite_code, active, created_at, updated_at) +VALUES ($1, $2, TRUE, NOW(), NOW()) +ON CONFLICT DO NOTHING +"#, + ) + .bind(user_id) + .bind(&code) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)? + .rows_affected() + } else if let Some(backend) = backends.mysql() { + sqlx::query( + r#" +INSERT IGNORE INTO user_invite_codes (user_id, invite_code, active, created_at, updated_at) +VALUES (?, ?, TRUE, ?, ?) +"#, + ) + .bind(user_id) + .bind(&code) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected() + } else if let Some(backend) = backends.sqlite() { + sqlx::query( + r#" +INSERT OR IGNORE INTO user_invite_codes (user_id, invite_code, active, created_at, updated_at) +VALUES (?, ?, 1, ?, ?) +"#, + ) + .bind(user_id) + .bind(&code) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected() + } else { + 0 + }; + if inserted > 0 { + return Ok(Some(code)); + } + if let Some(existing) = self.find_referral_invite_code(user_id).await? { + return Ok(Some(existing)); + } + } + self.find_referral_invite_code(user_id).await + } + + async fn find_referral_invite_code( + &self, + user_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + "SELECT invite_code FROM user_invite_codes WHERE user_id = $1 AND active = TRUE", + ) + .bind(user_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row + .map(|row| { + row.try_get::("invite_code") + .map_err(DataLayerError::sql) + }) + .transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + "SELECT invite_code FROM user_invite_codes WHERE user_id = ? AND active = TRUE", + ) + .bind(user_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row + .map(|row| { + row.try_get::("invite_code") + .map_err(DataLayerError::sql) + }) + .transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + "SELECT invite_code FROM user_invite_codes WHERE user_id = ? AND active = 1", + ) + .bind(user_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row + .map(|row| { + row.try_get::("invite_code") + .map_err(DataLayerError::sql) + }) + .transpose(); + } + Ok(None) + } + + async fn find_referral_inviter_by_code( + &self, + invite_code: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + "SELECT user_id FROM user_invite_codes WHERE invite_code = $1 AND active = TRUE", + ) + .bind(invite_code) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row + .map(|row| { + row.try_get::("user_id") + .map_err(DataLayerError::sql) + }) + .transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + "SELECT user_id FROM user_invite_codes WHERE invite_code = ? AND active = TRUE", + ) + .bind(invite_code) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row + .map(|row| { + row.try_get::("user_id") + .map_err(DataLayerError::sql) + }) + .transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + "SELECT user_id FROM user_invite_codes WHERE invite_code = ? AND active = 1", + ) + .bind(invite_code) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row + .map(|row| { + row.try_get::("user_id") + .map_err(DataLayerError::sql) + }) + .transpose(); + } + Ok(None) + } + + async fn insert_referral_relationship( + &self, + referral_id: &str, + inviter_user_id: &str, + invitee_user_id: &str, + invite_code: &str, + source_json: Option<&str>, + ) -> Result { + let Some(backends) = self.backends.as_ref() else { + return Ok(false); + }; + if let Some(backend) = backends.postgres() { + let affected = sqlx::query( + r#" +INSERT INTO user_referrals ( + id, inviter_user_id, invitee_user_id, invite_code_snapshot, source_json, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5::jsonb, NOW(), NOW()) +ON CONFLICT (invitee_user_id) DO NOTHING +"#, + ) + .bind(referral_id) + .bind(inviter_user_id) + .bind(invitee_user_id) + .bind(invite_code) + .bind(source_json) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.mysql() { + let affected = sqlx::query( + r#" +INSERT IGNORE INTO user_referrals ( + id, inviter_user_id, invitee_user_id, invite_code_snapshot, source_json, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?) +"#, + ) + .bind(referral_id) + .bind(inviter_user_id) + .bind(invitee_user_id) + .bind(invite_code) + .bind(source_json) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.sqlite() { + let affected = sqlx::query( + r#" +INSERT OR IGNORE INTO user_referrals ( + id, inviter_user_id, invitee_user_id, invite_code_snapshot, source_json, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?) +"#, + ) + .bind(referral_id) + .bind(inviter_user_id) + .bind(invitee_user_id) + .bind(invite_code) + .bind(source_json) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + Ok(false) + } + + async fn list_referral_relationships_raw( + &self, + inviter_user_id: Option<&str>, + invitee_user_id: Option<&str>, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(Vec::new()); + }; + if let Some(backend) = backends.postgres() { + let rows = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + EXTRACT(EPOCH FROM r.first_paid_at)::BIGINT AS first_paid_at_unix_secs, + r.source_json::TEXT AS source_json, + EXTRACT(EPOCH FROM r.created_at)::BIGINT AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE ($1::TEXT IS NULL OR r.inviter_user_id = $1) + AND ($2::TEXT IS NULL OR r.invitee_user_id = $2) +ORDER BY r.created_at DESC +LIMIT $3 +"#, + ) + .bind(inviter_user_id) + .bind(invitee_user_id) + .bind(REFERRAL_FETCH_LIMIT as i64) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return rows.iter().map(|row| relationship_from_row!(row)).collect(); + } + if let Some(backend) = backends.mysql() { + let rows = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + r.first_paid_at AS first_paid_at_unix_secs, + r.source_json AS source_json, + r.created_at AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE (? IS NULL OR r.inviter_user_id = ?) + AND (? IS NULL OR r.invitee_user_id = ?) +ORDER BY r.created_at DESC +LIMIT ? +"#, + ) + .bind(inviter_user_id) + .bind(inviter_user_id) + .bind(invitee_user_id) + .bind(invitee_user_id) + .bind(REFERRAL_FETCH_LIMIT as i64) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return rows.iter().map(|row| relationship_from_row!(row)).collect(); + } + if let Some(backend) = backends.sqlite() { + let rows = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + r.first_paid_at AS first_paid_at_unix_secs, + r.source_json AS source_json, + r.created_at AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE (? IS NULL OR r.inviter_user_id = ?) + AND (? IS NULL OR r.invitee_user_id = ?) +ORDER BY r.created_at DESC +LIMIT ? +"#, + ) + .bind(inviter_user_id) + .bind(inviter_user_id) + .bind(invitee_user_id) + .bind(invitee_user_id) + .bind(REFERRAL_FETCH_LIMIT as i64) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return rows.iter().map(|row| relationship_from_row!(row)).collect(); + } + Ok(Vec::new()) + } + + async fn find_referral_relationship( + &self, + referral_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + EXTRACT(EPOCH FROM r.first_paid_at)::BIGINT AS first_paid_at_unix_secs, + r.source_json::TEXT AS source_json, + EXTRACT(EPOCH FROM r.created_at)::BIGINT AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE r.id = $1 +LIMIT 1 +"#, + ) + .bind(referral_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(|row| relationship_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + r.first_paid_at AS first_paid_at_unix_secs, + r.source_json AS source_json, + r.created_at AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE r.id = ? +LIMIT 1 +"#, + ) + .bind(referral_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| relationship_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + r.first_paid_at AS first_paid_at_unix_secs, + r.source_json AS source_json, + r.created_at AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE r.id = ? +LIMIT 1 +"#, + ) + .bind(referral_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| relationship_from_row!(&row)).transpose(); + } + Ok(None) + } + + async fn find_referral_relationship_by_invitee( + &self, + invitee_user_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + EXTRACT(EPOCH FROM r.first_paid_at)::BIGINT AS first_paid_at_unix_secs, + r.source_json::TEXT AS source_json, + EXTRACT(EPOCH FROM r.created_at)::BIGINT AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE r.invitee_user_id = $1 +LIMIT 1 +"#, + ) + .bind(invitee_user_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(|row| relationship_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + r.first_paid_at AS first_paid_at_unix_secs, + r.source_json AS source_json, + r.created_at AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE r.invitee_user_id = ? +LIMIT 1 +"#, + ) + .bind(invitee_user_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| relationship_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT + r.id, r.inviter_user_id, inviter.username AS inviter_username, + r.invitee_user_id, invitee.username AS invitee_username, + r.invite_code_snapshot, r.first_paid_order_id, + r.first_paid_at AS first_paid_at_unix_secs, + r.source_json AS source_json, + r.created_at AS created_at_unix_secs +FROM user_referrals r +LEFT JOIN users inviter ON inviter.id = r.inviter_user_id +LEFT JOIN users invitee ON invitee.id = r.invitee_user_id +WHERE r.invitee_user_id = ? +LIMIT 1 +"#, + ) + .bind(invitee_user_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| relationship_from_row!(&row)).transpose(); + } + Ok(None) + } + + async fn list_referral_rewards_raw( + &self, + inviter_user_id: Option<&str>, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(Vec::new()); + }; + if let Some(backend) = backends.postgres() { + let rows = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs, + EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs +FROM referral_rewards +WHERE ($1::TEXT IS NULL OR inviter_user_id = $1) +ORDER BY created_at DESC +LIMIT $2 +"#, + ) + .bind(inviter_user_id) + .bind(REFERRAL_FETCH_LIMIT as i64) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return rows.iter().map(|row| reward_from_row!(row)).collect(); + } + if let Some(backend) = backends.mysql() { + let rows = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE (? IS NULL OR inviter_user_id = ?) +ORDER BY created_at DESC +LIMIT ? +"#, + ) + .bind(inviter_user_id) + .bind(inviter_user_id) + .bind(REFERRAL_FETCH_LIMIT as i64) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return rows.iter().map(|row| reward_from_row!(row)).collect(); + } + if let Some(backend) = backends.sqlite() { + let rows = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE (? IS NULL OR inviter_user_id = ?) +ORDER BY created_at DESC +LIMIT ? +"#, + ) + .bind(inviter_user_id) + .bind(inviter_user_id) + .bind(REFERRAL_FETCH_LIMIT as i64) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return rows.iter().map(|row| reward_from_row!(row)).collect(); + } + Ok(Vec::new()) + } + + async fn find_referral_reward( + &self, + reward_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs, + EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs +FROM referral_rewards +WHERE id = $1 +LIMIT 1 +"#, + ) + .bind(reward_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(|row| reward_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE id = ? +LIMIT 1 +"#, + ) + .bind(reward_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| reward_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE id = ? +LIMIT 1 +"#, + ) + .bind(reward_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| reward_from_row!(&row)).transpose(); + } + Ok(None) + } + + async fn find_referral_reward_by_idempotency_key( + &self, + idempotency_key: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs, + EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs +FROM referral_rewards +WHERE idempotency_key = $1 +LIMIT 1 +"#, + ) + .bind(idempotency_key) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(|row| reward_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE idempotency_key = ? +LIMIT 1 +"#, + ) + .bind(idempotency_key) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| reward_from_row!(&row)).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE idempotency_key = ? +LIMIT 1 +"#, + ) + .bind(idempotency_key) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(|row| reward_from_row!(&row)).transpose(); + } + Ok(None) + } + + async fn find_applied_referral_rewards_by_order( + &self, + order_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(Vec::new()); + }; + if let Some(backend) = backends.postgres() { + let rows = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs, + EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs +FROM referral_rewards +WHERE source_order_id = $1 AND status = 'applied' +ORDER BY created_at ASC +"#, + ) + .bind(order_id) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return rows.iter().map(|row| reward_from_row!(row)).collect(); + } + if let Some(backend) = backends.mysql() { + let rows = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE source_order_id = ? AND status = 'applied' +ORDER BY created_at ASC +"#, + ) + .bind(order_id) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return rows.iter().map(|row| reward_from_row!(row)).collect(); + } + if let Some(backend) = backends.sqlite() { + let rows = sqlx::query( + r#" +SELECT + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key, + reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note, + created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs +FROM referral_rewards +WHERE source_order_id = ? AND status = 'applied' +ORDER BY created_at ASC +"#, + ) + .bind(order_id) + .fetch_all(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return rows.iter().map(|row| reward_from_row!(row)).collect(); + } + Ok(Vec::new()) + } + + async fn insert_referral_reward( + &self, + relationship: &ReferralRelationshipRecord, + reward_type: &str, + source_order_id: Option<&str>, + trigger_point: &str, + amount_usd: f64, + idempotency_key: &str, + ) -> Result { + let Some(backends) = self.backends.as_ref() else { + return Ok(false); + }; + let reward_id = uuid::Uuid::new_v4().to_string(); + if let Some(backend) = backends.postgres() { + let affected = sqlx::query( + r#" +INSERT INTO referral_rewards ( + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, idempotency_key, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, NOW(), NOW()) +ON CONFLICT (idempotency_key) DO NOTHING +"#, + ) + .bind(&reward_id) + .bind(&relationship.id) + .bind(&relationship.inviter_user_id) + .bind(&relationship.invitee_user_id) + .bind(reward_type) + .bind(source_order_id) + .bind(trigger_point) + .bind(amount_usd) + .bind(idempotency_key) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.mysql() { + let affected = sqlx::query( + r#" +INSERT IGNORE INTO referral_rewards ( + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, idempotency_key, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) +"#, + ) + .bind(&reward_id) + .bind(&relationship.id) + .bind(&relationship.inviter_user_id) + .bind(&relationship.invitee_user_id) + .bind(reward_type) + .bind(source_order_id) + .bind(trigger_point) + .bind(amount_usd) + .bind(idempotency_key) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.sqlite() { + let affected = sqlx::query( + r#" +INSERT OR IGNORE INTO referral_rewards ( + id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id, + trigger_point, amount_usd, status, idempotency_key, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) +"#, + ) + .bind(&reward_id) + .bind(&relationship.id) + .bind(&relationship.inviter_user_id) + .bind(&relationship.invitee_user_id) + .bind(reward_type) + .bind(source_order_id) + .bind(trigger_point) + .bind(amount_usd) + .bind(idempotency_key) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + Ok(false) + } + + async fn find_referral_payment_order_context( + &self, + order_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT id, user_id, amount_usd, payment_method, status, order_kind +FROM payment_orders +WHERE id = $1 +"#, + ) + .bind(order_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(payment_order_context_from_row).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT id, user_id, amount_usd, payment_method, status, order_kind +FROM payment_orders +WHERE id = ? +"#, + ) + .bind(order_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(payment_order_context_from_row).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT id, user_id, amount_usd, payment_method, status, order_kind +FROM payment_orders +WHERE id = ? +"#, + ) + .bind(order_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(payment_order_context_from_row).transpose(); + } + Ok(None) + } + + async fn find_referral_payment_order_refund_context( + &self, + order_id: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT amount_usd, refunded_amount_usd +FROM payment_orders +WHERE id = $1 +"#, + ) + .bind(order_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(payment_order_refund_context_from_row).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT amount_usd, refunded_amount_usd +FROM payment_orders +WHERE id = ? +"#, + ) + .bind(order_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(payment_order_refund_context_from_row).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT amount_usd, refunded_amount_usd +FROM payment_orders +WHERE id = ? +"#, + ) + .bind(order_id) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(payment_order_refund_context_from_row).transpose(); + } + Ok(None) + } + + async fn mark_referral_first_paid_order( + &self, + referral_id: &str, + order_id: &str, + ) -> Result { + let Some(backends) = self.backends.as_ref() else { + return Ok(false); + }; + if let Some(backend) = backends.postgres() { + let affected = sqlx::query( + r#" +UPDATE user_referrals +SET first_paid_order_id = $2, + first_paid_at = NOW(), + updated_at = NOW() +WHERE id = $1 AND first_paid_order_id IS NULL +"#, + ) + .bind(referral_id) + .bind(order_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.mysql() { + let affected = sqlx::query( + r#" +UPDATE user_referrals +SET first_paid_order_id = ?, + first_paid_at = ?, + updated_at = ? +WHERE id = ? AND first_paid_order_id IS NULL +"#, + ) + .bind(order_id) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .bind(referral_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.sqlite() { + let affected = sqlx::query( + r#" +UPDATE user_referrals +SET first_paid_order_id = ?, + first_paid_at = ?, + updated_at = ? +WHERE id = ? AND first_paid_order_id IS NULL +"#, + ) + .bind(order_id) + .bind(now_unix_secs() as i64) + .bind(now_unix_secs() as i64) + .bind(referral_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + Ok(false) + } + + async fn update_referral_reward_status( + &self, + reward_id: &str, + status: &str, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result { + let Some(backends) = self.backends.as_ref() else { + return Ok(false); + }; + if let Some(backend) = backends.postgres() { + let affected = sqlx::query( + r#" +UPDATE referral_rewards +SET status = $2, + admin_operator_id = COALESCE($3, admin_operator_id), + admin_note = COALESCE($4, admin_note), + updated_at = NOW() +WHERE id = $1 AND status IN ('pending', 'failed') +"#, + ) + .bind(reward_id) + .bind(status) + .bind(operator_id) + .bind(note) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.mysql() { + let affected = sqlx::query( + r#" +UPDATE referral_rewards +SET status = ?, + admin_operator_id = COALESCE(?, admin_operator_id), + admin_note = COALESCE(?, admin_note), + updated_at = ? +WHERE id = ? AND status IN ('pending', 'failed') +"#, + ) + .bind(status) + .bind(operator_id) + .bind(note) + .bind(now_unix_secs() as i64) + .bind(reward_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + if let Some(backend) = backends.sqlite() { + let affected = sqlx::query( + r#" +UPDATE referral_rewards +SET status = ?, + admin_operator_id = COALESCE(?, admin_operator_id), + admin_note = COALESCE(?, admin_note), + updated_at = ? +WHERE id = ? AND status IN ('pending', 'failed') +"#, + ) + .bind(status) + .bind(operator_id) + .bind(note) + .bind(now_unix_secs() as i64) + .bind(reward_id) + .execute(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + return Ok(affected > 0); + } + Ok(false) + } + + async fn credit_pending_referral_rewards( + &self, + idempotency_keys: &[String], + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result, DataLayerError> { + let mut credited = Vec::new(); + for key in idempotency_keys { + if let Some(target) = self.referral_credit_target_by_key(key).await? { + self.credit_referral_reward(target, operator_id, note) + .await?; + if let Some(updated) = self.find_referral_reward_by_idempotency_key(key).await? { + credited.push(updated); + } + } + } + Ok(credited) + } + + async fn referral_credit_target_by_key( + &self, + idempotency_key: &str, + ) -> Result, DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(None); + }; + if let Some(backend) = backends.postgres() { + let row = sqlx::query( + r#" +SELECT + rw.id, rw.inviter_user_id, rw.invitee_user_id, rw.amount_usd, rw.reward_type, + rw.trigger_point, wallets.id AS wallet_id +FROM referral_rewards rw +JOIN wallets ON wallets.user_id = rw.inviter_user_id +WHERE rw.idempotency_key = $1 + AND rw.status IN ('pending', 'failed') +"#, + ) + .bind(idempotency_key) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::postgres)?; + return row.map(credit_target_from_row).transpose(); + } + if let Some(backend) = backends.mysql() { + let row = sqlx::query( + r#" +SELECT + rw.id, rw.inviter_user_id, rw.invitee_user_id, rw.amount_usd, rw.reward_type, + rw.trigger_point, wallets.id AS wallet_id +FROM referral_rewards rw +JOIN wallets ON wallets.user_id = rw.inviter_user_id +WHERE rw.idempotency_key = ? + AND rw.status IN ('pending', 'failed') +"#, + ) + .bind(idempotency_key) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(credit_target_from_row).transpose(); + } + if let Some(backend) = backends.sqlite() { + let row = sqlx::query( + r#" +SELECT + rw.id, rw.inviter_user_id, rw.invitee_user_id, rw.amount_usd, rw.reward_type, + rw.trigger_point, wallets.id AS wallet_id +FROM referral_rewards rw +JOIN wallets ON wallets.user_id = rw.inviter_user_id +WHERE rw.idempotency_key = ? + AND rw.status IN ('pending', 'failed') +"#, + ) + .bind(idempotency_key) + .fetch_optional(&backend.pool_clone()) + .await + .map_err(DataLayerError::sql)?; + return row.map(credit_target_from_row).transpose(); + } + Ok(None) + } + + async fn credit_referral_reward( + &self, + target: ReferralCreditTarget, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result<(), DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(()); + }; + if let Some(backend) = backends.postgres() { + let mut tx = backend + .pool_clone() + .begin() + .await + .map_err(DataLayerError::postgres)?; + let claimed = sqlx::query( + r#" +UPDATE referral_rewards +SET status = 'applying', + admin_operator_id = COALESCE($2, admin_operator_id), + admin_note = COALESCE($3, admin_note), + updated_at = NOW() +WHERE id = $1 AND status IN ('pending', 'failed') +"#, + ) + .bind(&target.id) + .bind(operator_id) + .bind(note) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)? + .rows_affected(); + if claimed == 0 { + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + } + let wallet = sqlx::query( + r#" +SELECT balance, gift_balance +FROM wallets +WHERE id = $1 +FOR UPDATE +"#, + ) + .bind(&target.wallet_id) + .fetch_optional(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + let Some(wallet) = wallet else { + sqlx::query( + r#" +UPDATE referral_rewards +SET status = 'failed', + admin_operator_id = COALESCE($2, admin_operator_id), + admin_note = COALESCE($3, admin_note), + updated_at = NOW() +WHERE id = $1 +"#, + ) + .bind(&target.id) + .bind(operator_id) + .bind(note.or(Some("邀请人钱包不存在"))) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + }; + let balance = row_f64!(wallet, "balance"); + let gift_before = row_f64!(wallet, "gift_balance"); + let gift_after = gift_before + target.amount_usd; + let tx_id = uuid::Uuid::new_v4().to_string(); + let description = note + .map(ToOwned::to_owned) + .unwrap_or_else(|| reward_description(&target)); + sqlx::query( + r#" +UPDATE wallets +SET gift_balance = $2, + total_adjusted = total_adjusted + $3, + updated_at = NOW() +WHERE id = $1 +"#, + ) + .bind(&target.wallet_id) + .bind(gift_after) + .bind(target.amount_usd) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + sqlx::query( + r#" +INSERT INTO wallet_transactions ( + id, wallet_id, category, reason_code, amount, + balance_before, balance_after, + recharge_balance_before, recharge_balance_after, + gift_balance_before, gift_balance_after, + link_type, link_id, operator_id, description, created_at +) +VALUES ($1, $2, 'adjust', 'referral_reward', $3, $4, $5, $6, $6, $7, $8, + 'referral_reward', $9, $10, $11, NOW()) +"#, + ) + .bind(&tx_id) + .bind(&target.wallet_id) + .bind(target.amount_usd) + .bind(balance + gift_before) + .bind(balance + gift_after) + .bind(balance) + .bind(gift_before) + .bind(gift_after) + .bind(&target.id) + .bind(operator_id) + .bind(&description) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + sqlx::query( + r#" +UPDATE referral_rewards +SET status = 'applied', + wallet_transaction_id = $2, + admin_operator_id = COALESCE($3, admin_operator_id), + admin_note = COALESCE($4, admin_note), + updated_at = NOW() +WHERE id = $1 +"#, + ) + .bind(&target.id) + .bind(&tx_id) + .bind(operator_id) + .bind(note) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + } + if let Some(backend) = backends.mysql() { + self.credit_referral_reward_mysql_numeric_time( + &backend.pool_clone(), + target, + operator_id, + note, + ) + .await?; + return Ok(()); + } + if let Some(backend) = backends.sqlite() { + self.credit_referral_reward_sqlite_numeric_time( + &backend.pool_clone(), + target, + operator_id, + note, + ) + .await?; + return Ok(()); + } + Ok(()) + } + + async fn apply_referral_reward_reversal( + &self, + reward: &ReferralRewardRecord, + target_reversal_amount_usd: f64, + ) -> Result<(), DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(()); + }; + if let Some(backend) = backends.postgres() { + let mut tx = backend + .pool_clone() + .begin() + .await + .map_err(DataLayerError::postgres)?; + let reward_row = sqlx::query( + r#" +SELECT reversed_amount_usd, pending_reversal_amount_usd +FROM referral_rewards +WHERE id = $1 +FOR UPDATE +"#, + ) + .bind(&reward.id) + .fetch_optional(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + let Some(reward_row) = reward_row else { + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + }; + let current_reversed = row_f64!(reward_row, "reversed_amount_usd"); + let current_pending = row_f64!(reward_row, "pending_reversal_amount_usd"); + let amount_usd = + (target_reversal_amount_usd - current_reversed - current_pending).max(0.0); + if amount_usd <= 0.0 { + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + } + let wallet = sqlx::query( + r#" +SELECT id, balance, gift_balance +FROM wallets +WHERE user_id = $1 +FOR UPDATE +"#, + ) + .bind(&reward.inviter_user_id) + .fetch_optional(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + let Some(wallet) = wallet else { + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + }; + let wallet_id = row_string!(wallet, "id"); + let balance = row_f64!(wallet, "balance"); + let gift_before = row_f64!(wallet, "gift_balance"); + let actual_reverse = gift_before.min(amount_usd); + let pending_reverse = (amount_usd - actual_reverse).max(0.0); + let gift_after = gift_before - actual_reverse; + let tx_id = uuid::Uuid::new_v4().to_string(); + if actual_reverse > 0.0 { + sqlx::query( + r#" +UPDATE wallets +SET gift_balance = $2, + total_adjusted = total_adjusted - $3, + updated_at = NOW() +WHERE id = $1 +"#, + ) + .bind(&wallet_id) + .bind(gift_after) + .bind(actual_reverse) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + sqlx::query( + r#" +INSERT INTO wallet_transactions ( + id, wallet_id, category, reason_code, amount, + balance_before, balance_after, + recharge_balance_before, recharge_balance_after, + gift_balance_before, gift_balance_after, + link_type, link_id, description, created_at +) +VALUES ($1, $2, 'adjust', 'referral_reward_reversal', $3, $4, $5, $6, $6, $7, $8, + 'referral_reward', $9, '邀请返利退款冲回', NOW()) +"#, + ) + .bind(&tx_id) + .bind(&wallet_id) + .bind(-actual_reverse) + .bind(balance + gift_before) + .bind(balance + gift_after) + .bind(balance) + .bind(gift_before) + .bind(gift_after) + .bind(&reward.id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + } + sqlx::query( + r#" +UPDATE referral_rewards +SET reversed_amount_usd = reversed_amount_usd + $2, + pending_reversal_amount_usd = pending_reversal_amount_usd + $3, + status = CASE + WHEN reversed_amount_usd + $2 >= amount_usd THEN 'reversed' + ELSE status + END, + updated_at = NOW() +WHERE id = $1 +"#, + ) + .bind(&reward.id) + .bind(actual_reverse) + .bind(pending_reverse) + .execute(&mut *tx) + .await + .map_err(DataLayerError::postgres)?; + tx.commit().await.map_err(DataLayerError::postgres)?; + return Ok(()); + } + // MySQL/SQLite refunds use the wallet repository tables with integer timestamps. + self.apply_referral_reward_reversal_numeric_time(reward, target_reversal_amount_usd) + .await + } +} + +macro_rules! referral_credit_numeric_method { + ($name:ident, $pool_ty:ty, $wallet_sql:expr) => { + async fn $name( + &self, + pool: &$pool_ty, + target: ReferralCreditTarget, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result<(), DataLayerError> { + let mut tx = pool.begin().await.map_err(DataLayerError::sql)?; + let claimed = sqlx::query( + r#" +UPDATE referral_rewards +SET status = 'applying', + admin_operator_id = COALESCE(?, admin_operator_id), + admin_note = COALESCE(?, admin_note), + updated_at = ? +WHERE id = ? AND status IN ('pending', 'failed') +"#, + ) + .bind(operator_id) + .bind(note) + .bind(now_unix_secs() as i64) + .bind(&target.id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)? + .rows_affected(); + if claimed == 0 { + tx.commit().await.map_err(DataLayerError::sql)?; + return Ok(()); + } + let wallet = sqlx::query($wallet_sql) + .bind(&target.wallet_id) + .fetch_optional(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + let Some(wallet) = wallet else { + sqlx::query( + r#" +UPDATE referral_rewards +SET status = 'failed', + admin_operator_id = COALESCE(?, admin_operator_id), + admin_note = COALESCE(?, admin_note), + updated_at = ? +WHERE id = ? +"#, + ) + .bind(operator_id) + .bind(note.or(Some("邀请人钱包不存在"))) + .bind(now_unix_secs() as i64) + .bind(&target.id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + tx.commit().await.map_err(DataLayerError::sql)?; + return Ok(()); + }; + let balance = row_f64!(wallet, "balance"); + let gift_before = row_f64!(wallet, "gift_balance"); + let gift_after = gift_before + target.amount_usd; + let tx_id = uuid::Uuid::new_v4().to_string(); + let description = note + .map(ToOwned::to_owned) + .unwrap_or_else(|| reward_description(&target)); + sqlx::query( + r#" +UPDATE wallets +SET gift_balance = ?, + total_adjusted = total_adjusted + ?, + updated_at = ? +WHERE id = ? +"#, + ) + .bind(gift_after) + .bind(target.amount_usd) + .bind(now_unix_secs() as i64) + .bind(&target.wallet_id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + sqlx::query( + r#" +INSERT INTO wallet_transactions ( + id, wallet_id, category, reason_code, amount, + balance_before, balance_after, + recharge_balance_before, recharge_balance_after, + gift_balance_before, gift_balance_after, + link_type, link_id, operator_id, description, created_at +) +VALUES (?, ?, 'adjust', 'referral_reward', ?, ?, ?, ?, ?, ?, ?, + 'referral_reward', ?, ?, ?, ?) +"#, + ) + .bind(&tx_id) + .bind(&target.wallet_id) + .bind(target.amount_usd) + .bind(balance + gift_before) + .bind(balance + gift_after) + .bind(balance) + .bind(balance) + .bind(gift_before) + .bind(gift_after) + .bind(&target.id) + .bind(operator_id) + .bind(&description) + .bind(now_unix_ms() as i64) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + sqlx::query( + r#" +UPDATE referral_rewards +SET status = 'applied', + wallet_transaction_id = ?, + admin_operator_id = COALESCE(?, admin_operator_id), + admin_note = COALESCE(?, admin_note), + updated_at = ? +WHERE id = ? +"#, + ) + .bind(&tx_id) + .bind(operator_id) + .bind(note) + .bind(now_unix_secs() as i64) + .bind(&target.id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + tx.commit().await.map_err(DataLayerError::sql)?; + Ok(()) + } + }; +} + +impl GatewayDataState { + referral_credit_numeric_method!( + credit_referral_reward_mysql_numeric_time, + sqlx::MySqlPool, + "SELECT balance, gift_balance FROM wallets WHERE id = ? FOR UPDATE" + ); + referral_credit_numeric_method!( + credit_referral_reward_sqlite_numeric_time, + sqlx::SqlitePool, + "SELECT balance, gift_balance FROM wallets WHERE id = ?" + ); + + async fn apply_referral_reward_reversal_numeric_time( + &self, + reward: &ReferralRewardRecord, + target_reversal_amount_usd: f64, + ) -> Result<(), DataLayerError> { + let Some(backends) = self.backends.as_ref() else { + return Ok(()); + }; + if let Some(backend) = backends.mysql() { + return apply_referral_reward_reversal_for_mysql_pool( + &backend.pool_clone(), + reward, + target_reversal_amount_usd, + ) + .await; + } + if let Some(backend) = backends.sqlite() { + return apply_referral_reward_reversal_for_sqlite_pool( + &backend.pool_clone(), + reward, + target_reversal_amount_usd, + ) + .await; + } + Ok(()) + } +} + +fn payment_order_context_from_row(row: R) -> Result +where + R: Row, + for<'c> &'c str: sqlx::ColumnIndex, + for<'r> String: sqlx::Decode<'r, R::Database> + sqlx::Type, + for<'r> Option: sqlx::Decode<'r, R::Database> + sqlx::Type, + for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + let Some(user_id) = row_optional_string!(row, "user_id") else { + return Err(DataLayerError::InvalidInput( + "payment order has no user_id".to_string(), + )); + }; + Ok(ReferralPaymentOrderContext { + id: row_string!(row, "id"), + user_id, + amount_usd: row_f64!(row, "amount_usd"), + payment_method: row_string!(row, "payment_method"), + status: row_string!(row, "status"), + order_kind: row_string!(row, "order_kind"), + }) +} + +fn payment_order_refund_context_from_row( + row: R, +) -> Result +where + R: Row, + for<'c> &'c str: sqlx::ColumnIndex, + for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + Ok(ReferralPaymentOrderRefundContext { + amount_usd: row_f64!(row, "amount_usd"), + refunded_amount_usd: row_f64!(row, "refunded_amount_usd"), + }) +} + +fn credit_target_from_row(row: R) -> Result +where + R: Row, + for<'c> &'c str: sqlx::ColumnIndex, + for<'r> String: sqlx::Decode<'r, R::Database> + sqlx::Type, + for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + Ok(ReferralCreditTarget { + id: row_string!(row, "id"), + wallet_id: row_string!(row, "wallet_id"), + inviter_user_id: row_string!(row, "inviter_user_id"), + invitee_user_id: row_string!(row, "invitee_user_id"), + amount_usd: row_f64!(row, "amount_usd"), + reward_type: row_string!(row, "reward_type"), + trigger_point: row_string!(row, "trigger_point"), + }) +} + +macro_rules! referral_reversal_numeric_fn { + ($name:ident, $pool_ty:ty, $wallet_sql:expr) => { + async fn $name( + pool: &$pool_ty, + reward: &ReferralRewardRecord, + target_reversal_amount_usd: f64, + ) -> Result<(), DataLayerError> { + let mut tx = pool.begin().await.map_err(DataLayerError::sql)?; + sqlx::query("UPDATE referral_rewards SET updated_at = updated_at WHERE id = ?") + .bind(&reward.id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + let reward_row = sqlx::query( + r#" +SELECT reversed_amount_usd, pending_reversal_amount_usd +FROM referral_rewards +WHERE id = ? +"#, + ) + .bind(&reward.id) + .fetch_optional(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + let Some(reward_row) = reward_row else { + tx.commit().await.map_err(DataLayerError::sql)?; + return Ok(()); + }; + let current_reversed = row_f64!(reward_row, "reversed_amount_usd"); + let current_pending = row_f64!(reward_row, "pending_reversal_amount_usd"); + let amount_usd = + (target_reversal_amount_usd - current_reversed - current_pending).max(0.0); + if amount_usd <= 0.0 { + tx.commit().await.map_err(DataLayerError::sql)?; + return Ok(()); + } + let wallet = sqlx::query($wallet_sql) + .bind(&reward.inviter_user_id) + .fetch_optional(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + let Some(wallet) = wallet else { + tx.commit().await.map_err(DataLayerError::sql)?; + return Ok(()); + }; + let wallet_id = row_string!(wallet, "id"); + let balance = row_f64!(wallet, "balance"); + let gift_before = row_f64!(wallet, "gift_balance"); + let actual_reverse = gift_before.min(amount_usd); + let pending_reverse = (amount_usd - actual_reverse).max(0.0); + let gift_after = gift_before - actual_reverse; + if actual_reverse > 0.0 { + sqlx::query( + r#" +UPDATE wallets +SET gift_balance = ?, + total_adjusted = total_adjusted - ?, + updated_at = ? +WHERE id = ? +"#, + ) + .bind(gift_after) + .bind(actual_reverse) + .bind(now_unix_secs() as i64) + .bind(&wallet_id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + sqlx::query( + r#" +INSERT INTO wallet_transactions ( + id, wallet_id, category, reason_code, amount, + balance_before, balance_after, + recharge_balance_before, recharge_balance_after, + gift_balance_before, gift_balance_after, + link_type, link_id, description, created_at +) +VALUES (?, ?, 'adjust', 'referral_reward_reversal', ?, ?, ?, ?, ?, ?, ?, + 'referral_reward', ?, '邀请返利退款冲回', ?) +"#, + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&wallet_id) + .bind(-actual_reverse) + .bind(balance + gift_before) + .bind(balance + gift_after) + .bind(balance) + .bind(balance) + .bind(gift_before) + .bind(gift_after) + .bind(&reward.id) + .bind(now_unix_ms() as i64) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + } + sqlx::query( + r#" +UPDATE referral_rewards +SET reversed_amount_usd = reversed_amount_usd + ?, + pending_reversal_amount_usd = pending_reversal_amount_usd + ?, + status = CASE + WHEN reversed_amount_usd + ? >= amount_usd THEN 'reversed' + ELSE status + END, + updated_at = ? +WHERE id = ? +"#, + ) + .bind(actual_reverse) + .bind(pending_reverse) + .bind(actual_reverse) + .bind(now_unix_secs() as i64) + .bind(&reward.id) + .execute(&mut *tx) + .await + .map_err(DataLayerError::sql)?; + tx.commit().await.map_err(DataLayerError::sql)?; + Ok(()) + } + }; +} + +referral_reversal_numeric_fn!( + apply_referral_reward_reversal_for_mysql_pool, + sqlx::MySqlPool, + "SELECT id, balance, gift_balance FROM wallets WHERE user_id = ? FOR UPDATE" +); +referral_reversal_numeric_fn!( + apply_referral_reward_reversal_for_sqlite_pool, + sqlx::SqlitePool, + "SELECT id, balance, gift_balance FROM wallets WHERE user_id = ?" +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn referral_retry_only_allows_failed_rewards() { + assert!(referral_retry_allowed("failed")); + + for status in ["pending", "applied", "reversed", "voided"] { + assert!(!referral_retry_allowed(status), "{status} must not retry"); + } + } + + #[test] + fn referral_void_only_allows_pending_and_failed_rewards() { + assert!(referral_void_allowed("pending")); + assert!(referral_void_allowed("failed")); + + for status in ["applied", "reversed", "voided"] { + assert!(!referral_void_allowed(status), "{status} must not void"); + } + } + + #[test] + fn referral_reversal_delta_uses_cumulative_refund_ratio() { + let first = referral_reversal_delta(10.0, 100.0, 20.0, 0.0, 0.0); + assert!((first - 2.0).abs() < f64::EPSILON); + + let second = referral_reversal_delta(10.0, 100.0, 50.0, 2.0, 0.0); + assert!((second - 3.0).abs() < f64::EPSILON); + + let repeated = referral_reversal_delta(10.0, 100.0, 50.0, 2.0, 3.0); + assert_eq!(repeated, 0.0); + + let full = referral_reversal_delta(10.0, 100.0, 125.0, 5.0, 0.0); + assert!((full - 5.0).abs() < f64::EPSILON); + } +} diff --git a/apps/aether-gateway/src/data/state/runtime.rs b/apps/aether-gateway/src/data/state/runtime.rs index b442dc55b..52b0cfbe5 100644 --- a/apps/aether-gateway/src/data/state/runtime.rs +++ b/apps/aether-gateway/src/data/state/runtime.rs @@ -262,6 +262,22 @@ impl GatewayDataState { } } + pub(crate) async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, DataLayerError> { + match &self.announcement_reader { + Some(repository) => { + repository + .list_required_unread_active_announcements(user_id, now_unix_secs, limit) + .await + } + None => Ok(Vec::new()), + } + } + pub(crate) async fn create_announcement( &self, record: CreateAnnouncementRecord, diff --git a/apps/aether-gateway/src/handlers/admin/billing/payments/orders.rs b/apps/aether-gateway/src/handlers/admin/billing/payments/orders.rs index df085c676..ced653c93 100644 --- a/apps/aether-gateway/src/handlers/admin/billing/payments/orders.rs +++ b/apps/aether-gateway/src/handlers/admin/billing/payments/orders.rs @@ -16,6 +16,7 @@ use axum::{ Json, }; use serde_json::json; +use tracing::warn; pub(super) async fn maybe_build_local_admin_payment_orders_response( state: &AdminAppState<'_>, @@ -211,6 +212,19 @@ async fn build_admin_payment_credit_order_response( .await? { crate::AdminWalletMutationOutcome::Applied((order, credited)) => { + if credited { + if let Err(err) = state + .app() + .apply_referral_rewards_for_payment_order_id(&order.id) + .await + { + warn!( + error = ?err, + order_id = %order.id, + "failed to apply referral rewards for admin-credited payment order" + ); + } + } Ok(attach_admin_audit_response( Json(json!({ "order": build_admin_payment_order_payload(&order), diff --git a/apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/complete_refund.rs b/apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/complete_refund.rs index 11882d8d1..86e5b32e2 100644 --- a/apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/complete_refund.rs +++ b/apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/complete_refund.rs @@ -14,6 +14,7 @@ use axum::{ Json, }; use serde_json::json; +use tracing::warn; pub(in super::super) async fn build_admin_wallet_complete_refund_response( state: &AdminAppState<'_>, @@ -86,6 +87,20 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response( .await? { crate::AdminWalletMutationOutcome::Applied(refund) => { + if let Some(order_id) = refund.payment_order_id.as_deref() { + if let Err(err) = state + .app() + .reverse_referral_rewards_for_order(order_id, refund.amount_usd) + .await + { + warn!( + error = ?err, + order_id = %order_id, + refund_id = %refund.id, + "failed to reverse referral rewards for completed refund" + ); + } + } let response = Json(json!({ "refund": build_admin_wallet_refund_payload(&wallet, &owner, &refund), })) diff --git a/apps/aether-gateway/src/handlers/admin/mod.rs b/apps/aether-gateway/src/handlers/admin/mod.rs index 14fa07192..754caccd0 100644 --- a/apps/aether-gateway/src/handlers/admin/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/mod.rs @@ -6,6 +6,7 @@ pub(super) mod features; mod model; pub(super) mod observability; pub(super) mod provider; +mod referrals; mod system; mod users; diff --git a/apps/aether-gateway/src/handlers/admin/referrals.rs b/apps/aether-gateway/src/handlers/admin/referrals.rs new file mode 100644 index 000000000..b81674a5b --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/referrals.rs @@ -0,0 +1,278 @@ +use crate::data::state::{ReferralRelationshipListQuery, ReferralRewardListQuery}; +use crate::handlers::admin::request::{AdminRouteRequest, AdminRouteResult}; +use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value}; +use crate::GatewayError; +use axum::{ + body::Body, + http, + response::{IntoResponse, Response}, + Json, +}; +use serde::Deserialize; +use serde_json::json; + +#[derive(Debug, Default, Deserialize)] +struct ReferralAdminMutationRequest { + note: Option, +} + +pub(crate) async fn maybe_build_local_admin_referrals_response( + request: AdminRouteRequest<'_>, +) -> AdminRouteResult { + let request_context = request.request_context(); + let Some(decision) = request_context.decision() else { + return Ok(None); + }; + if decision.route_family.as_deref() != Some("referrals_manage") { + return Ok(None); + } + let response = match decision.route_kind.as_deref() { + Some("list_referrals") => { + build_admin_referrals_list_response(&request.state(), &request_context).await? + } + Some("list_referral_rewards") => { + build_admin_referral_rewards_list_response(&request.state(), &request_context).await? + } + Some("retry_referral_reward") => { + build_admin_referral_reward_retry_response( + &request.state(), + &request_context, + request.request_body(), + ) + .await? + } + Some("void_referral_reward") => { + build_admin_referral_reward_void_response( + &request.state(), + &request_context, + request.request_body(), + ) + .await? + } + _ => build_admin_referrals_unavailable_response(), + }; + Ok(Some(response)) +} + +fn admin_referrals_bad_request(detail: impl Into) -> Response { + ( + http::StatusCode::BAD_REQUEST, + Json(json!({ "detail": detail.into() })), + ) + .into_response() +} + +fn build_admin_referrals_unavailable_response() -> Response { + ( + http::StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "detail": "Admin referral data unavailable" })), + ) + .into_response() +} + +fn parse_limit(query: Option<&str>) -> Result { + match query_param_value(query, "limit") { + Some(value) => value + .parse::() + .map(|value| value.clamp(1, 200)) + .map_err(|_| "limit 必须是正整数".to_string()), + None => Ok(50), + } +} + +fn parse_offset(query: Option<&str>) -> Result { + match query_param_value(query, "offset") { + Some(value) => value + .parse::() + .map_err(|_| "offset 必须是非负整数".to_string()), + None => Ok(0), + } +} + +fn parse_optional_bool(query: Option<&str>, key: &str) -> Result, String> { + let Some(value) = query_param_value(query, key) else { + return Ok(None); + }; + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(Some(true)), + "false" | "0" | "no" => Ok(Some(false)), + _ => Err(format!("{key} 必须是布尔值")), + } +} + +fn operator_id( + request_context: &crate::handlers::admin::request::AdminRequestContext<'_>, +) -> Option { + request_context + .decision() + .and_then(|decision| decision.admin_principal.as_ref()) + .map(|principal| principal.user_id.clone()) +} + +fn reward_id_from_path(path: &str, suffix: &str) -> Option { + let trimmed = path.trim_end_matches('/'); + let rest = trimmed.strip_prefix("/api/admin/referral-rewards/")?; + let id = rest.strip_suffix(suffix)?.trim_end_matches('/'); + (!id.is_empty()).then_some(id.to_string()) +} + +fn parse_mutation_note(body: Option<&axum::body::Bytes>) -> Result, String> { + let Some(body) = body.filter(|body| !body.is_empty()) else { + return Ok(None); + }; + let payload = serde_json::from_slice::(body) + .map_err(|_| "请求数据验证失败".to_string())?; + Ok(payload + .note + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())) +} + +async fn build_admin_referrals_list_response( + state: &crate::handlers::admin::request::AdminAppState<'_>, + request_context: &crate::handlers::admin::request::AdminRequestContext<'_>, +) -> Result, GatewayError> { + let query = request_context.query_string(); + let limit = match parse_limit(query) { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + let offset = match parse_offset(query) { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + let first_paid = match parse_optional_bool(query, "first_paid") { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + let Some((items, total, stats)) = state + .app() + .list_admin_referral_relationships(ReferralRelationshipListQuery { + inviter: query_param_value(query, "inviter"), + invitee: query_param_value(query, "invitee"), + invite_code: query_param_value(query, "invite_code"), + first_paid, + limit, + offset, + }) + .await? + else { + return Ok(build_admin_referrals_unavailable_response()); + }; + Ok(Json(json!({ + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "stats": stats, + })) + .into_response()) +} + +async fn build_admin_referral_rewards_list_response( + state: &crate::handlers::admin::request::AdminAppState<'_>, + request_context: &crate::handlers::admin::request::AdminRequestContext<'_>, +) -> Result, GatewayError> { + let query = request_context.query_string(); + let limit = match parse_limit(query) { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + let offset = match parse_offset(query) { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + let Some((items, total, stats)) = state + .app() + .list_admin_referral_rewards(ReferralRewardListQuery { + order_id: query_param_value(query, "order_id"), + reward_type: query_param_value(query, "reward_type"), + status: query_param_value(query, "status"), + limit, + offset, + }) + .await? + else { + return Ok(build_admin_referrals_unavailable_response()); + }; + Ok(Json(json!({ + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "stats": stats, + })) + .into_response()) +} + +async fn build_admin_referral_reward_retry_response( + state: &crate::handlers::admin::request::AdminAppState<'_>, + request_context: &crate::handlers::admin::request::AdminRequestContext<'_>, + request_body: Option<&axum::body::Bytes>, +) -> Result, GatewayError> { + let Some(reward_id) = reward_id_from_path(request_context.path(), "/retry") else { + return Ok(admin_referrals_bad_request("返利记录不存在")); + }; + let note = match parse_mutation_note(request_body) { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + match state + .app() + .retry_referral_reward( + &reward_id, + operator_id(request_context).as_deref(), + note.as_deref(), + ) + .await? + { + Some(reward) => Ok(attach_admin_audit_response( + Json(json!({ "reward": reward })).into_response(), + "admin_referral_reward_retry", + "retry_referral_reward", + "referral_reward", + &reward_id, + )), + None => Ok(( + http::StatusCode::NOT_FOUND, + Json(json!({ "detail": "Referral reward not found" })), + ) + .into_response()), + } +} + +async fn build_admin_referral_reward_void_response( + state: &crate::handlers::admin::request::AdminAppState<'_>, + request_context: &crate::handlers::admin::request::AdminRequestContext<'_>, + request_body: Option<&axum::body::Bytes>, +) -> Result, GatewayError> { + let Some(reward_id) = reward_id_from_path(request_context.path(), "/void") else { + return Ok(admin_referrals_bad_request("返利记录不存在")); + }; + let note = match parse_mutation_note(request_body) { + Ok(value) => value, + Err(detail) => return Ok(admin_referrals_bad_request(detail)), + }; + match state + .app() + .void_referral_reward( + &reward_id, + operator_id(request_context).as_deref(), + note.as_deref(), + ) + .await? + { + Some(reward) => Ok(attach_admin_audit_response( + Json(json!({ "reward": reward })).into_response(), + "admin_referral_reward_void", + "void_referral_reward", + "referral_reward", + &reward_id, + )), + None => Ok(( + http::StatusCode::NOT_FOUND, + Json(json!({ "detail": "Referral reward not found" })), + ) + .into_response()), + } +} diff --git a/apps/aether-gateway/src/handlers/admin/routes.rs b/apps/aether-gateway/src/handlers/admin/routes.rs index 79f6a0913..8f0bbcecd 100644 --- a/apps/aether-gateway/src/handlers/admin/routes.rs +++ b/apps/aether-gateway/src/handlers/admin/routes.rs @@ -1,6 +1,6 @@ use super::{ - announcements, auth, billing, endpoint, features, model, observability, provider, request, - system, users, + announcements, auth, billing, endpoint, features, model, observability, provider, referrals, + request, system, users, }; pub(crate) async fn maybe_build_local_admin_response( @@ -40,6 +40,10 @@ pub(crate) async fn maybe_build_local_admin_response( return Ok(Some(response)); } + if let Some(response) = referrals::maybe_build_local_admin_referrals_response(request).await? { + return Ok(Some(response)); + } + if let Some(response) = features::maybe_build_local_admin_features_response(request).await? { return Ok(Some(response)); } diff --git a/apps/aether-gateway/src/handlers/admin/users/groups.rs b/apps/aether-gateway/src/handlers/admin/users/groups.rs index e07c940cf..a596598b7 100644 --- a/apps/aether-gateway/src/handlers/admin/users/groups.rs +++ b/apps/aether-gateway/src/handlers/admin/users/groups.rs @@ -14,6 +14,7 @@ use axum::{ Json, }; use serde_json::json; +use std::collections::BTreeSet; #[derive(Debug, serde::Deserialize)] struct AdminUserGroupPayload { @@ -209,14 +210,18 @@ pub(in super::super) async fn build_admin_replace_user_group_members_response( if state.find_user_group_by_id(&group_id).await?.is_none() { return Ok(not_found("用户分组不存在")); } - if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str()) { - return Ok(bad_request_owned("默认用户组成员由系统维护".to_string())); - } let payload = match parse_members_payload(request_body) { Ok(value) => value, Err(detail) => return Ok(bad_request_owned(detail)), }; let user_ids = normalize_ids(payload.user_ids); + if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str()) { + if let Some(response) = + validate_default_group_member_replacement(state, &group_id, &user_ids).await? + { + return Ok(response); + } + } let known_users = state.resolve_auth_user_summaries_by_ids(&user_ids).await?; if known_users.len() != user_ids.len() { return Ok(bad_request_owned("成员包含不存在的用户".to_string())); @@ -245,6 +250,52 @@ pub(in super::super) async fn build_admin_replace_user_group_members_response( )) } +async fn validate_default_group_member_replacement( + state: &AdminAppState<'_>, + group_id: &str, + next_user_ids: &[String], +) -> Result>, GatewayError> { + let next_user_ids = next_user_ids.iter().cloned().collect::>(); + let removed_user_ids = state + .list_user_group_members(group_id) + .await? + .into_iter() + .filter(|member| !next_user_ids.contains(&member.user_id)) + .map(|member| member.user_id) + .collect::>(); + if removed_user_ids.is_empty() { + return Ok(None); + } + + let summaries = state + .resolve_auth_user_summaries_by_ids(&removed_user_ids) + .await?; + let users_with_other_groups = state + .list_user_group_memberships_by_user_ids(&removed_user_ids) + .await? + .into_iter() + .filter(|membership| membership.group_id != group_id) + .map(|membership| membership.user_id) + .collect::>(); + + for user_id in removed_user_ids { + let Some(summary) = summaries.get(&user_id) else { + continue; + }; + if crate::roles::can_access_admin_console(&summary.role) { + continue; + } + if !users_with_other_groups.contains(&user_id) { + return Ok(Some(bad_request_owned(format!( + "用户 {} 移出默认组后将不属于任何用户组", + summary.username + )))); + } + } + + Ok(None) +} + pub(in super::super) async fn build_admin_set_default_user_group_response( state: &AdminAppState<'_>, request_body: Option<&axum::body::Bytes>, diff --git a/apps/aether-gateway/src/handlers/public/support/announcements/admin_routes.rs b/apps/aether-gateway/src/handlers/public/support/announcements/admin_routes.rs index cd1298dfc..4938c81f7 100644 --- a/apps/aether-gateway/src/handlers/public/support/announcements/admin_routes.rs +++ b/apps/aether-gateway/src/handlers/public/support/announcements/admin_routes.rs @@ -32,6 +32,7 @@ struct AdminAnnouncementCreateRequest { kind: String, priority: Option, is_pinned: Option, + requires_ack: Option, start_time: Option, end_time: Option, } @@ -45,6 +46,7 @@ struct AdminAnnouncementUpdateRequest { priority: Option, is_active: Option, is_pinned: Option, + requires_ack: Option, start_time: Option, end_time: Option, } @@ -168,6 +170,7 @@ fn build_create_record( kind: payload.kind, priority: payload.priority.unwrap_or(0), is_pinned: payload.is_pinned.unwrap_or(false), + requires_ack: payload.requires_ack.unwrap_or(false), author_id: operator_id, start_time_unix_secs: parse_optional_rfc3339_unix_secs( payload.start_time.as_deref(), @@ -194,6 +197,7 @@ fn build_update_record( priority: payload.priority, is_active: payload.is_active, is_pinned: payload.is_pinned, + requires_ack: payload.requires_ack, start_time_unix_secs: parse_optional_rfc3339_unix_secs( payload.start_time.as_deref(), "start_time", diff --git a/apps/aether-gateway/src/handlers/public/support/announcements/shared.rs b/apps/aether-gateway/src/handlers/public/support/announcements/shared.rs index a9590f147..b86e36672 100644 --- a/apps/aether-gateway/src/handlers/public/support/announcements/shared.rs +++ b/apps/aether-gateway/src/handlers/public/support/announcements/shared.rs @@ -78,6 +78,7 @@ pub(super) fn build_public_announcement_payload( "priority": announcement.priority, "is_active": announcement.is_active, "is_pinned": announcement.is_pinned, + "requires_ack": announcement.requires_ack, "author": { "id": announcement.author_id, "username": announcement.author_username, diff --git a/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs b/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs index eb7d100c8..06e54dee6 100644 --- a/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs +++ b/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs @@ -13,7 +13,7 @@ use super::super::{build_unhandled_public_support_response, resolve_authenticate use super::announcements_shared::{ announcements_bad_request_response, announcements_internal_detail, announcements_internal_error_response, announcements_not_found_response, - read_status_announcement_id_from_path, + build_public_announcement_payload, read_status_announcement_id_from_path, }; #[derive(Debug, serde::Deserialize)] @@ -75,6 +75,37 @@ pub(crate) async fn maybe_build_local_announcement_user_response( }; Some(Json(json!({ "unread_count": unread_count })).into_response()) } + Some("required_unread") + if request_context.request_method == http::Method::GET + && matches!( + request_context.request_path.as_str(), + "/api/announcements/users/me/required-unread" + | "/api/announcements/users/me/required-unread/" + ) => + { + let items = match state + .list_required_unread_active_announcements(&auth.user.id, now_unix_secs, 20) + .await + { + Ok(value) => value, + Err(err) => { + return Some(announcements_internal_error_response( + announcements_internal_detail(err), + )) + } + }; + let payload_items = items + .iter() + .map(build_public_announcement_payload) + .collect::>(); + Some( + Json(json!({ + "items": payload_items, + "total": payload_items.len(), + })) + .into_response(), + ) + } Some("read_all") if request_context.request_method == http::Method::POST && matches!( diff --git a/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs b/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs index ba6f5e41a..be87f53d9 100644 --- a/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs +++ b/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs @@ -26,6 +26,18 @@ pub(crate) async fn build_auth_registration_settings_payload( let turnstile_site_key_config = state .read_system_config_json_value("turnstile_site_key") .await?; + let privacy_enabled_config = state + .read_system_config_json_value("registration_privacy_policy_enabled") + .await?; + let privacy_format_config = state + .read_system_config_json_value("registration_privacy_policy_format") + .await?; + let privacy_content_config = state + .read_system_config_json_value("registration_privacy_policy_content") + .await?; + let privacy_version_config = state + .read_system_config_json_value("registration_privacy_policy_version") + .await?; let email_configured = smtp_host .as_ref() @@ -48,6 +60,15 @@ pub(crate) async fn build_auth_registration_settings_payload( }; let turnstile_enabled = system_config_bool(turnstile_enabled_config.as_ref(), false); let turnstile_site_key = system_config_string(turnstile_site_key_config.as_ref()); + let privacy_policy_enabled = system_config_bool(privacy_enabled_config.as_ref(), false); + let privacy_policy_format = match system_config_string(privacy_format_config.as_ref()) { + Some(value) if matches!(value.as_str(), "markdown" | "html") => value, + _ => "markdown".to_string(), + }; + let privacy_policy_content = + system_config_string(privacy_content_config.as_ref()).unwrap_or_default(); + let privacy_policy_version = + system_config_string(privacy_version_config.as_ref()).unwrap_or_else(|| "1".to_string()); Ok(json!({ "enable_registration": enable_registration, @@ -57,6 +78,12 @@ pub(crate) async fn build_auth_registration_settings_payload( "turnstile_enabled": turnstile_enabled, "turnstile_site_key": turnstile_site_key, "turnstile_required_actions": ["send_verification_code", "register"], + "privacy_policy": { + "enabled": privacy_policy_enabled, + "format": privacy_policy_format, + "content": privacy_policy_content, + "version": privacy_policy_version, + }, })) } diff --git a/apps/aether-gateway/src/handlers/public/support/auth_registration.rs b/apps/aether-gateway/src/handlers/public/support/auth_registration.rs index 3f382536d..d7f35a442 100644 --- a/apps/aether-gateway/src/handlers/public/support/auth_registration.rs +++ b/apps/aether-gateway/src/handlers/public/support/auth_registration.rs @@ -18,6 +18,9 @@ struct AuthRegisterRequest { username: String, password: String, turnstile_token: Option, + invite_code: Option, + privacy_policy_accepted: Option, + privacy_policy_version: Option, } #[derive(Debug, Deserialize)] @@ -131,6 +134,26 @@ pub(crate) fn validate_auth_register_password(password: &str, policy: &str) -> R Ok(()) } +struct RegistrationPrivacyPolicySettings { + enabled: bool, + version: String, +} + +async fn read_registration_privacy_policy_settings( + state: &AppState, +) -> Result { + let enabled = state + .read_system_config_json_value("registration_privacy_policy_enabled") + .await?; + let version = state + .read_system_config_json_value("registration_privacy_policy_version") + .await?; + Ok(RegistrationPrivacyPolicySettings { + enabled: system_config_bool(enabled.as_ref(), false), + version: system_config_string(version.as_ref()).unwrap_or_else(|| "1".to_string()), + }) +} + pub(crate) async fn auth_password_policy_level(state: &AppState) -> Result { let config = state .read_system_config_json_value("password_policy_level") @@ -388,6 +411,31 @@ pub(super) async fn handle_auth_register( if !enable_registration { return build_auth_error_response(http::StatusCode::FORBIDDEN, "系统暂不开放注册", false); } + let privacy_policy = match read_registration_privacy_policy_settings(state).await { + Ok(value) => value, + Err(err) => { + return build_auth_error_response( + http::StatusCode::INTERNAL_SERVER_ERROR, + format!("auth settings lookup failed: {err:?}"), + false, + ); + } + }; + if privacy_policy.enabled { + let accepted = payload.privacy_policy_accepted.unwrap_or(false); + let accepted_version = payload + .privacy_policy_version + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if !accepted || accepted_version != privacy_policy.version { + return build_auth_error_response( + http::StatusCode::BAD_REQUEST, + "请先阅读并同意当前版本的隐私政策", + false, + ); + } + } if let Err(response) = verify_auth_turnstile( state, @@ -555,6 +603,63 @@ pub(super) async fn handle_auth_register( false, ); } + if privacy_policy.enabled { + match state + .record_user_privacy_policy_acceptance(&user.id, &privacy_policy.version) + .await + { + Ok(true) => {} + Ok(false) => { + let _ = state.delete_local_auth_user(&user.id).await; + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + AUTH_REGISTRATION_STORAGE_UNAVAILABLE_DETAIL, + false, + ); + } + Err(err) => { + let _ = state.delete_local_auth_user(&user.id).await; + return build_auth_error_response( + http::StatusCode::INTERNAL_SERVER_ERROR, + format!("auth privacy policy acceptance failed: {err:?}"), + false, + ); + } + } + } + let invite_code = payload + .invite_code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if invite_code.is_some() { + let source = json!({ + "channel": "registration", + "ip": cf_connecting_ip, + "user_agent": headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()), + }); + if let Err(err) = state + .bind_referral_invite_after_registration( + &user.id, + user.email_verified, + invite_code, + Some(source), + ) + .await + { + let _ = state.delete_local_auth_user(&user.id).await; + let (status, detail) = match err { + GatewayError::Client { status, message } => (status, message), + other => ( + http::StatusCode::INTERNAL_SERVER_ERROR, + format!("auth referral binding failed: {other:?}"), + ), + }; + return build_auth_error_response(status, detail, false); + } + } if require_verification { if let Some(email) = email.as_deref() { diff --git a/apps/aether-gateway/src/handlers/public/support/payment/epay.rs b/apps/aether-gateway/src/handlers/public/support/payment/epay.rs index 8f7c619fd..c69d7a2c9 100644 --- a/apps/aether-gateway/src/handlers/public/support/payment/epay.rs +++ b/apps/aether-gateway/src/handlers/public/support/payment/epay.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use axum::{body::Body, http, response::Response}; use md5::{Digest, Md5}; use serde_json::json; +use tracing::warn; use super::{payment_shared::payment_callback_payload_hash, AppState, GatewayPublicRequestContext}; @@ -373,9 +374,20 @@ pub(super) async fn handle_epay_notify( match outcome { Ok(Some(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Applied { + order, + order_id, .. - })) - | Ok(Some( + })) => { + if let Err(err) = state.apply_referral_rewards_for_paid_order(&order).await { + warn!( + error = ?err, + order_id = %order_id, + "failed to apply referral rewards for epay callback" + ); + } + epay_plain(http::StatusCode::OK, "success") + } + Ok(Some( aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited { .. }, diff --git a/apps/aether-gateway/src/handlers/public/support/payment/repository.rs b/apps/aether-gateway/src/handlers/public/support/payment/repository.rs index 49ed0fe8b..f83d440a4 100644 --- a/apps/aether-gateway/src/handlers/public/support/payment/repository.rs +++ b/apps/aether-gateway/src/handlers/public/support/payment/repository.rs @@ -10,6 +10,7 @@ use super::{ build_auth_error_response, build_payment_callback_storage_unavailable_response, AppState, GatewayPublicRequestContext, }; +use tracing::warn; pub(super) async fn handle_payment_callback_with_wallet_repository( state: &AppState, @@ -109,21 +110,30 @@ pub(super) async fn handle_payment_callback_with_wallet_repository( order_no, wallet_id, order, - } => build_auth_json_response( - http::StatusCode::OK, - json!({ - "ok": true, - "duplicate": duplicate, - "credited": true, - "order_id": order_id, - "order_no": order_no, - "status": order.status, - "wallet_id": wallet_id, - "payment_method": payment_method, - "request_path": request_context.request_path, - }), - None, - ), + } => { + if let Err(err) = state.apply_referral_rewards_for_paid_order(&order).await { + warn!( + error = ?err, + order_id = %order_id, + "failed to apply referral rewards for credited payment order" + ); + } + build_auth_json_response( + http::StatusCode::OK, + json!({ + "ok": true, + "duplicate": duplicate, + "credited": true, + "order_id": order_id, + "order_no": order_no, + "status": order.status, + "wallet_id": wallet_id, + "payment_method": payment_method, + "request_path": request_context.request_path, + }), + None, + ) + } } } diff --git a/apps/aether-gateway/src/handlers/public/support/user_me.rs b/apps/aether-gateway/src/handlers/public/support/user_me.rs index 295dc5adf..02ebacc1c 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me.rs @@ -31,6 +31,9 @@ use user_me_catalog::*; #[path = "user_me_preferences.rs"] mod user_me_preferences; use user_me_preferences::*; +#[path = "user_me_referral.rs"] +mod user_me_referral; +use user_me_referral::*; #[path = "user_me_profile.rs"] mod user_me_profile; use user_me_profile::*; diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_referral.rs b/apps/aether-gateway/src/handlers/public/support/user_me_referral.rs new file mode 100644 index 000000000..b7575d540 --- /dev/null +++ b/apps/aether-gateway/src/handlers/public/support/user_me_referral.rs @@ -0,0 +1,69 @@ +use super::{ + build_auth_error_response, resolve_authenticated_local_user, AppState, + GatewayPublicRequestContext, +}; +use axum::{ + body::Body, + http, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +pub(super) async fn handle_users_me_referral_get( + state: &AppState, + request_context: &GatewayPublicRequestContext, + headers: &http::HeaderMap, +) -> Response { + let auth = match resolve_authenticated_local_user(state, request_context, headers).await { + Ok(value) => value, + Err(response) => return response, + }; + if !state.has_referral_data_backend() { + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "邀请返利数据暂不可用", + false, + ); + } + let dashboard = match state.referral_dashboard(&auth.user.id).await { + Ok(Some(value)) => value, + Ok(None) => { + return build_auth_error_response( + http::StatusCode::SERVICE_UNAVAILABLE, + "邀请返利数据暂不可用", + false, + ); + } + Err(err) => { + return build_auth_error_response( + http::StatusCode::INTERNAL_SERVER_ERROR, + format!("referral dashboard failed: {err:?}"), + false, + ); + } + }; + let base = headers + .get("origin") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default(); + let invitation_link = if base.is_empty() { + format!("/register?invite={}", dashboard.invite_code) + } else { + format!("{base}/register?invite={}", dashboard.invite_code) + }; + Json(json!({ + "invite_code": dashboard.invite_code, + "invitation_link": invitation_link, + "summary": { + "total_invites": dashboard.total_invites, + "effective_invites": dashboard.effective_invites, + "paid_reward_usd": dashboard.paid_reward_usd, + "pending_reward_usd": dashboard.pending_reward_usd, + "reversed_reward_usd": dashboard.reversed_reward_usd, + } + })) + .into_response() +} diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs b/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs index 78745f6f3..88355dfee 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me_routes.rs @@ -15,11 +15,12 @@ use super::{ handle_users_me_management_tokens_list, handle_users_me_model_capabilities_get, handle_users_me_model_capabilities_put, handle_users_me_password_patch, handle_users_me_preferences_get, handle_users_me_preferences_put, - handle_users_me_providers_get, handle_users_me_sessions_get, handle_users_me_update_session, - handle_users_me_usage_active_get, handle_users_me_usage_get, handle_users_me_usage_heatmap_get, - handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches, - users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches, - users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches, + handle_users_me_providers_get, handle_users_me_referral_get, handle_users_me_sessions_get, + handle_users_me_update_session, handle_users_me_usage_active_get, handle_users_me_usage_get, + handle_users_me_usage_heatmap_get, handle_users_me_usage_interval_timeline_get, + users_me_api_key_capabilities_path_matches, users_me_api_key_detail_path_matches, + users_me_api_key_install_sessions_path_matches, users_me_api_key_providers_path_matches, + users_me_management_token_detail_path_matches, users_me_management_token_regenerate_path_matches, users_me_management_token_toggle_path_matches, users_me_management_tokens_root, users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext, @@ -211,6 +212,9 @@ pub(crate) async fn maybe_build_local_users_me_response( Some("preferences") if request_context.request_path == "/api/users/me/preferences" => { Some(handle_users_me_preferences_get(state, request_context, headers).await) } + Some("referral") if request_context.request_path == "/api/users/me/referral" => { + Some(handle_users_me_referral_get(state, request_context, headers).await) + } Some("available_models") if request_context.request_path == "/api/users/me/available-models" => { diff --git a/apps/aether-gateway/src/state/runtime/announcements.rs b/apps/aether-gateway/src/state/runtime/announcements.rs index 9f34d12c6..259ec82b9 100644 --- a/apps/aether-gateway/src/state/runtime/announcements.rs +++ b/apps/aether-gateway/src/state/runtime/announcements.rs @@ -33,6 +33,18 @@ impl AppState { .map_err(|err| GatewayError::Internal(err.to_string())) } + pub(crate) async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, GatewayError> { + self.data + .list_required_unread_active_announcements(user_id, now_unix_secs, limit) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + pub(crate) async fn create_announcement( &self, record: aether_data::repository::announcements::CreateAnnouncementRecord, diff --git a/apps/aether-gateway/src/state/runtime/auth/user_lifecycle.rs b/apps/aether-gateway/src/state/runtime/auth/user_lifecycle.rs index 7475b1d78..6dc3267ca 100644 --- a/apps/aether-gateway/src/state/runtime/auth/user_lifecycle.rs +++ b/apps/aether-gateway/src/state/runtime/auth/user_lifecycle.rs @@ -81,6 +81,14 @@ impl AppState { pub(crate) async fn add_all_users_to_group(&self, group_id: &str) -> Result<(), GatewayError> { for user in self.list_non_admin_export_users().await? { + let has_other_group = self + .list_user_groups_for_user(&user.id) + .await? + .into_iter() + .any(|group| group.id != group_id); + if has_other_group { + continue; + } self.add_user_to_group(group_id, &user.id).await?; } Ok(()) diff --git a/apps/aether-gateway/src/state/runtime/mod.rs b/apps/aether-gateway/src/state/runtime/mod.rs index 906104827..2b14edf8c 100644 --- a/apps/aether-gateway/src/state/runtime/mod.rs +++ b/apps/aether-gateway/src/state/runtime/mod.rs @@ -16,6 +16,7 @@ mod candidate_queries; mod gemini_files; mod monitoring; mod payments; +mod referrals; mod security; mod usage_queries; mod user_preferences; diff --git a/apps/aether-gateway/src/state/runtime/referrals.rs b/apps/aether-gateway/src/state/runtime/referrals.rs new file mode 100644 index 000000000..e1e1b9c1f --- /dev/null +++ b/apps/aether-gateway/src/state/runtime/referrals.rs @@ -0,0 +1,241 @@ +use crate::data::state::{ + ReferralRelationshipListQuery, ReferralRelationshipRecord, ReferralRewardConfig, + ReferralRewardListQuery, ReferralRewardRecord, ReferralUserDashboard, +}; +use crate::{AppState, GatewayError}; +use axum::http::StatusCode; + +fn referral_data_error(err: aether_data::DataLayerError) -> GatewayError { + match err { + aether_data::DataLayerError::InvalidInput(detail) => GatewayError::Client { + status: StatusCode::BAD_REQUEST, + message: detail, + }, + other => GatewayError::Internal(other.to_string()), + } +} + +fn config_bool(value: Option<&serde_json::Value>, default: bool) -> bool { + match value { + Some(serde_json::Value::Bool(value)) => *value, + Some(serde_json::Value::String(value)) => { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => true, + "false" | "0" | "no" | "off" => false, + _ => default, + } + } + Some(serde_json::Value::Number(value)) => { + value.as_i64().map(|value| value != 0).unwrap_or(default) + } + _ => default, + } +} + +fn config_string(value: Option<&serde_json::Value>) -> Option { + match value { + Some(serde_json::Value::String(value)) => { + let value = value.trim(); + (!value.is_empty()).then_some(value.to_string()) + } + Some(value) => Some(value.to_string()), + None => None, + } +} + +fn config_f64(value: Option<&serde_json::Value>, default: f64) -> f64 { + match value { + Some(serde_json::Value::Number(value)) => value.as_f64().unwrap_or(default), + Some(serde_json::Value::String(value)) => value.trim().parse::().unwrap_or(default), + _ => default, + } +} + +impl AppState { + pub(crate) fn has_referral_data_backend(&self) -> bool { + self.data.has_referral_data_backend() + } + + pub(crate) async fn record_user_privacy_policy_acceptance( + &self, + user_id: &str, + version: &str, + ) -> Result { + self.data + .record_user_privacy_policy_acceptance(user_id, version) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn referral_reward_config( + &self, + ) -> Result, GatewayError> { + let enabled = self + .read_system_config_json_value("referral_enabled") + .await?; + if !config_bool(enabled.as_ref(), false) { + return Ok(None); + } + let mode = self + .read_system_config_json_value("referral_reward_mode") + .await?; + let mode = config_string(mode.as_ref()).unwrap_or_else(|| "percent".to_string()); + let percent = self + .read_system_config_json_value("referral_recharge_percent") + .await?; + let headcount_amount = self + .read_system_config_json_value("referral_headcount_amount_usd") + .await?; + let headcount_trigger = self + .read_system_config_json_value("referral_headcount_trigger") + .await?; + let headcount_trigger = + config_string(headcount_trigger.as_ref()).unwrap_or_else(|| "registration".to_string()); + Ok(Some(ReferralRewardConfig { + percent_enabled: matches!(mode.as_str(), "percent" | "both"), + percent_rate: config_f64(percent.as_ref(), 0.0), + headcount_enabled: matches!(mode.as_str(), "headcount" | "both"), + headcount_amount_usd: config_f64(headcount_amount.as_ref(), 0.0), + headcount_trigger, + })) + } + + pub(crate) async fn bind_referral_invite_after_registration( + &self, + user_id: &str, + email_verified: bool, + invite_code: Option<&str>, + source: Option, + ) -> Result<(), GatewayError> { + let Some(config) = self.referral_reward_config().await? else { + return Ok(()); + }; + let relationship = self + .data + .bind_referral_invite_code(user_id, invite_code, source) + .await + .map_err(referral_data_error)?; + let trigger_matches = config.headcount_trigger == "registration" + || (config.headcount_trigger == "email_verified" && email_verified); + if relationship.is_some() + && config.headcount_enabled + && trigger_matches + && config.headcount_amount_usd > 0.0 + { + self.data + .apply_registration_referral_reward( + user_id, + config.headcount_amount_usd, + &config.headcount_trigger, + ) + .await + .map_err(|err| GatewayError::Internal(err.to_string()))?; + } + Ok(()) + } + + pub(crate) async fn referral_dashboard( + &self, + user_id: &str, + ) -> Result, GatewayError> { + self.data + .referral_dashboard(user_id) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn list_admin_referral_relationships( + &self, + query: ReferralRelationshipListQuery, + ) -> Result< + Option<( + Vec, + u64, + crate::data::state::ReferralAdminStats, + )>, + GatewayError, + > { + self.data + .list_admin_referral_relationships(query) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn list_admin_referral_rewards( + &self, + query: ReferralRewardListQuery, + ) -> Result< + Option<( + Vec, + u64, + crate::data::state::ReferralAdminStats, + )>, + GatewayError, + > { + self.data + .list_admin_referral_rewards(query) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn retry_referral_reward( + &self, + reward_id: &str, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result, GatewayError> { + self.data + .retry_referral_reward(reward_id, operator_id, note) + .await + .map_err(referral_data_error) + } + + pub(crate) async fn void_referral_reward( + &self, + reward_id: &str, + operator_id: Option<&str>, + note: Option<&str>, + ) -> Result, GatewayError> { + self.data + .void_referral_reward(reward_id, operator_id, note) + .await + .map_err(referral_data_error) + } + + pub(crate) async fn apply_referral_rewards_for_paid_order( + &self, + order: &aether_data::repository::wallet::StoredAdminPaymentOrder, + ) -> Result, GatewayError> { + let Some(config) = self.referral_reward_config().await? else { + return Ok(Vec::new()); + }; + self.data + .apply_paid_order_referral_rewards(&order.id, config) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn apply_referral_rewards_for_payment_order_id( + &self, + order_id: &str, + ) -> Result, GatewayError> { + let Some(config) = self.referral_reward_config().await? else { + return Ok(Vec::new()); + }; + self.data + .apply_paid_order_referral_rewards(order_id, config) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn reverse_referral_rewards_for_order( + &self, + order_id: &str, + amount_usd: f64, + ) -> Result, GatewayError> { + self.data + .reverse_referral_rewards_for_order(order_id, amount_usd) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } +} diff --git a/apps/aether-gateway/src/tests/control/admin/users.rs b/apps/aether-gateway/src/tests/control/admin/users.rs index ec56c5d35..25a0b9de6 100644 --- a/apps/aether-gateway/src/tests/control/admin/users.rs +++ b/apps/aether-gateway/src/tests/control/admin/users.rs @@ -567,6 +567,121 @@ async fn gateway_allows_default_user_group_access_policy_updates() { upstream_handle.abort(); } +#[tokio::test] +async fn gateway_allows_removing_default_group_members_when_other_group_remains() { + let upstream = Router::new().fallback(any(|_request: Request| async { + (StatusCode::OK, Body::from("unexpected upstream hit")) + })); + + let user_repository = Arc::new( + InMemoryUserReadRepository::seed_auth_users(vec![ + sample_admin_user_with_role("admin-1", "admin", "admin@example.com", "admin"), + sample_admin_user_with_role("user-2", "user", "bob@example.com", "bob"), + sample_admin_user_with_role("user-3", "user", "carol@example.com", "carol"), + ]) + .with_export_users(vec![ + sample_admin_export_user_with("admin", true, "admin-1", "admin@example.com", "admin"), + sample_admin_export_user_with("user", true, "user-2", "bob@example.com", "bob"), + sample_admin_export_user_with("user", true, "user-3", "carol@example.com", "carol"), + ]), + ); + let default_group = user_repository + .create_user_group(UpsertUserGroupRecord { + name: "Default".to_string(), + description: None, + priority: 0, + allowed_providers: None, + allowed_providers_mode: "unrestricted".to_string(), + allowed_api_formats: None, + allowed_api_formats_mode: "unrestricted".to_string(), + allowed_models: None, + allowed_models_mode: "unrestricted".to_string(), + rate_limit: None, + rate_limit_mode: "system".to_string(), + }) + .await + .expect("default group should create") + .expect("default group should exist"); + let team_group = user_repository + .create_user_group(UpsertUserGroupRecord { + name: "Team".to_string(), + description: None, + priority: 0, + allowed_providers: None, + allowed_providers_mode: "unrestricted".to_string(), + allowed_api_formats: None, + allowed_api_formats_mode: "unrestricted".to_string(), + allowed_models: None, + allowed_models_mode: "unrestricted".to_string(), + rate_limit: None, + rate_limit_mode: "system".to_string(), + }) + .await + .expect("team group should create") + .expect("team group should exist"); + user_repository + .add_user_to_group(&team_group.id, "user-2") + .await + .expect("team membership should create"); + + let (upstream_url, upstream_handle) = start_server(upstream).await; + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_user_reader_for_tests(user_repository.clone()) + .with_system_config_values_for_tests(vec![( + crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY.to_string(), + json!(default_group.id), + )]), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + + user_repository + .add_user_to_group(&default_group.id, "user-2") + .await + .expect("default membership should create"); + user_repository + .add_user_to_group(&default_group.id, "user-3") + .await + .expect("default membership should create"); + + let remove_user_with_other_group = client + .put(format!( + "{gateway_url}/api/admin/user-groups/{}/members", + default_group.id + )) + .header(GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .json(&json!({ "user_ids": ["user-3"] })) + .send() + .await + .expect("request should succeed"); + assert_eq!(remove_user_with_other_group.status(), StatusCode::OK); + + let reject_groupless_user = client + .put(format!( + "{gateway_url}/api/admin/user-groups/{}/members", + default_group.id + )) + .header(GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .json(&json!({ "user_ids": [] })) + .send() + .await + .expect("request should succeed"); + assert_eq!(reject_groupless_user.status(), StatusCode::BAD_REQUEST); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_resolves_admin_user_batch_selection_locally() { let upstream_hits = Arc::new(Mutex::new(0usize)); diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support.rs b/apps/aether-gateway/src/tests/frontdoor/public_support.rs index 5ae9744bc..fb1fcb61a 100644 --- a/apps/aether-gateway/src/tests/frontdoor/public_support.rs +++ b/apps/aether-gateway/src/tests/frontdoor/public_support.rs @@ -69,6 +69,7 @@ async fn gateway_handles_public_announcements_list_without_proxying_upstream() { 5, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -85,6 +86,7 @@ async fn gateway_handles_public_announcements_list_without_proxying_upstream() { 3, true, false, + false, Some("admin-2".to_string()), Some("ops".to_string()), None, @@ -101,6 +103,7 @@ async fn gateway_handles_public_announcements_list_without_proxying_upstream() { 100, false, true, + false, Some("admin-3".to_string()), Some("root".to_string()), None, @@ -170,6 +173,7 @@ async fn gateway_handles_public_active_announcements_without_proxying_upstream() 50, true, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), Some((now.saturating_sub(60)) as i64), @@ -186,6 +190,7 @@ async fn gateway_handles_public_active_announcements_without_proxying_upstream() 10, true, false, + false, Some("admin-2".to_string()), Some("ops".to_string()), Some((now.saturating_add(3600)) as i64), @@ -251,6 +256,7 @@ async fn gateway_handles_public_announcement_detail_without_proxying_upstream() 10, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), Some(1_711_000_000), @@ -390,6 +396,7 @@ async fn gateway_updates_announcement_locally_with_trusted_admin_principal() { 10, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -479,6 +486,7 @@ async fn gateway_deletes_announcement_locally_with_trusted_admin_principal() { 10, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -561,6 +569,7 @@ async fn gateway_returns_service_unavailable_for_admin_announcement_writes_witho 10, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -1436,6 +1445,22 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream() "turnstile_secret_key".to_string(), json!("secret-private-key"), ), + ( + "registration_privacy_policy_enabled".to_string(), + json!(true), + ), + ( + "registration_privacy_policy_format".to_string(), + json!("html"), + ), + ( + "registration_privacy_policy_content".to_string(), + json!("

Policy

"), + ), + ( + "registration_privacy_policy_version".to_string(), + json!("2026-05-16"), + ), ]); let (upstream_url, upstream_handle) = start_server(upstream).await; @@ -1464,6 +1489,12 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream() "turnstile_enabled": true, "turnstile_site_key": "site-public-key", "turnstile_required_actions": ["send_verification_code", "register"], + "privacy_policy": { + "enabled": true, + "format": "html", + "content": "

Policy

", + "version": "2026-05-16", + }, }) ); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); @@ -2839,6 +2870,7 @@ async fn gateway_reads_announcement_unread_count_locally_without_proxying_upstre 10, true, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -2855,6 +2887,7 @@ async fn gateway_reads_announcement_unread_count_locally_without_proxying_upstre 8, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -2871,6 +2904,7 @@ async fn gateway_reads_announcement_unread_count_locally_without_proxying_upstre 6, false, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -2917,6 +2951,122 @@ async fn gateway_reads_announcement_unread_count_locally_without_proxying_upstre upstream_handle.abort(); } +#[tokio::test] +async fn gateway_lists_required_unread_announcements_locally_without_proxying_upstream() { + let now = Utc::now(); + let user = sample_auth_user(now); + let access_token = build_test_auth_token( + "access", + serde_json::Map::from_iter([ + ("user_id".to_string(), json!(user.id)), + ("role".to_string(), json!(user.role)), + ( + "created_at".to_string(), + json!(user.created_at.map(|value| value.to_rfc3339())), + ), + ( + "session_id".to_string(), + json!("session-announcement-required-1"), + ), + ]), + now + chrono::Duration::hours(1), + ); + let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed_with_reads( + vec![ + StoredAnnouncement::new( + "announcement-required".to_string(), + "必读公告".to_string(), + "需要确认".to_string(), + "important".to_string(), + 20, + true, + false, + true, + Some("admin-1".to_string()), + Some("admin".to_string()), + None, + None, + now.timestamp(), + now.timestamp(), + ) + .expect("announcement should build"), + StoredAnnouncement::new( + "announcement-normal".to_string(), + "普通公告".to_string(), + "不需要弹窗".to_string(), + "info".to_string(), + 10, + true, + false, + false, + Some("admin-1".to_string()), + Some("admin".to_string()), + None, + None, + now.timestamp(), + now.timestamp(), + ) + .expect("announcement should build"), + StoredAnnouncement::new( + "announcement-read-required".to_string(), + "已读必读公告".to_string(), + "已经确认".to_string(), + "warning".to_string(), + 8, + true, + false, + true, + Some("admin-1".to_string()), + Some("admin".to_string()), + None, + None, + now.timestamp(), + now.timestamp(), + ) + .expect("announcement should build"), + ], + [( + "user-auth-1".to_string(), + "announcement-read-required".to_string(), + )], + )); + let (gateway_url, upstream_hits, gateway_handle, upstream_handle) = + start_auth_announcement_gateway_with_state( + user, + sample_auth_wallet("user-auth-1", now), + [sample_auth_session( + "user-auth-1", + "session-announcement-required-1", + "device-announcement-required-1", + "refresh-token-placeholder", + now, + )], + announcement_repository, + ) + .await; + + let response = reqwest::Client::new() + .get(format!( + "{gateway_url}/api/announcements/users/me/required-unread" + )) + .header("authorization", format!("Bearer {access_token}")) + .header("x-client-device-id", "device-announcement-required-1") + .header("user-agent", "AetherTest/1.0") + .send() + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["total"], 1); + assert_eq!(payload["items"][0]["id"], "announcement-required"); + assert_eq!(payload["items"][0]["requires_ack"], true); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_marks_announcement_read_status_locally_without_proxying_upstream() { let now = Utc::now(); @@ -2946,6 +3096,7 @@ async fn gateway_marks_announcement_read_status_locally_without_proxying_upstrea 20, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -3040,6 +3191,7 @@ async fn gateway_marks_all_announcements_read_locally_without_proxying_upstream( 10, true, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -3056,6 +3208,7 @@ async fn gateway_marks_all_announcements_read_locally_without_proxying_upstream( 8, false, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -3072,6 +3225,7 @@ async fn gateway_marks_all_announcements_read_locally_without_proxying_upstream( 6, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -3163,6 +3317,7 @@ async fn gateway_handles_announcement_user_routes_with_trailing_slash_locally() 10, true, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -3323,6 +3478,7 @@ async fn gateway_rejects_invalid_nested_announcement_paths_as_local_not_found_wi 10, true, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -7918,6 +8074,54 @@ async fn gateway_handles_auth_register_locally_without_proxying_upstream() { upstream_handle.abort(); } +#[tokio::test] +async fn gateway_rejects_auth_register_without_current_privacy_policy_acceptance() { + let (gateway_url, upstream_hits, gateway_handle, upstream_handle) = + start_auth_gateway_with_builder(|| { + let data_state = crate::data::GatewayDataState::disabled() + .with_system_config_values_for_tests(vec![ + ("enable_registration".to_string(), json!(true)), + ("require_email_verification".to_string(), json!(true)), + ("smtp_host".to_string(), json!("smtp.example.com")), + ("smtp_from_email".to_string(), json!("ops@example.com")), + ( + "registration_privacy_policy_enabled".to_string(), + json!(true), + ), + ( + "registration_privacy_policy_version".to_string(), + json!("2026-05-16"), + ), + ]); + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(data_state) + .with_auth_email_verified_for_tests("alice@example.com") + }) + .await; + + let response = reqwest::Client::new() + .post(format!("{gateway_url}/api/auth/register")) + .json(&json!({ + "email": "alice@example.com", + "username": "alice", + "password": "secret123", + "privacy_policy_accepted": true, + "privacy_policy_version": "old-version", + })) + .send() + .await + .expect("register request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["detail"], "请先阅读并同意当前版本的隐私政策"); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + async fn start_turnstile_siteverify_server( response_payload: serde_json::Value, status: StatusCode, diff --git a/crates/aether-data/migrations/mysql/20260516000000_add_referrals_privacy_required_announcements.sql b/crates/aether-data/migrations/mysql/20260516000000_add_referrals_privacy_required_announcements.sql new file mode 100644 index 000000000..c6ace42e9 --- /dev/null +++ b/crates/aether-data/migrations/mysql/20260516000000_add_referrals_privacy_required_announcements.sql @@ -0,0 +1,59 @@ +ALTER TABLE users + ADD COLUMN privacy_policy_accepted_version VARCHAR(64), + ADD COLUMN privacy_policy_accepted_at BIGINT; + +ALTER TABLE announcements + ADD COLUMN requires_ack BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS user_invite_codes ( + user_id VARCHAR(64) PRIMARY KEY, + invite_code VARCHAR(64) NOT NULL UNIQUE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CONSTRAINT user_invite_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_referrals ( + id VARCHAR(64) PRIMARY KEY, + inviter_user_id VARCHAR(64) NOT NULL, + invitee_user_id VARCHAR(64) NOT NULL UNIQUE, + invite_code_snapshot VARCHAR(64) NOT NULL, + source_json TEXT, + first_paid_order_id VARCHAR(64), + first_paid_at BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_user_referrals_inviter (inviter_user_id, created_at), + KEY idx_user_referrals_invite_code (invite_code_snapshot), + CONSTRAINT user_referrals_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT user_referrals_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT user_referrals_first_paid_order_fkey FOREIGN KEY (first_paid_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS referral_rewards ( + id VARCHAR(64) PRIMARY KEY, + referral_id VARCHAR(64) NOT NULL, + inviter_user_id VARCHAR(64) NOT NULL, + invitee_user_id VARCHAR(64) NOT NULL, + reward_type VARCHAR(32) NOT NULL, + trigger_point VARCHAR(64) NOT NULL, + source_order_id VARCHAR(64), + idempotency_key VARCHAR(128) NOT NULL UNIQUE, + amount_usd DOUBLE NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + wallet_transaction_id VARCHAR(64), + reversed_amount_usd DOUBLE NOT NULL DEFAULT 0, + pending_reversal_amount_usd DOUBLE NOT NULL DEFAULT 0, + failure_reason TEXT, + admin_operator_id VARCHAR(64), + admin_note TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_referral_rewards_inviter_status (inviter_user_id, status, created_at), + KEY idx_referral_rewards_source_order (source_order_id), + CONSTRAINT referral_rewards_referral_id_fkey FOREIGN KEY (referral_id) REFERENCES user_referrals(id) ON DELETE CASCADE, + CONSTRAINT referral_rewards_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT referral_rewards_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT referral_rewards_source_order_fkey FOREIGN KEY (source_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL +); diff --git a/crates/aether-data/migrations/postgres/20260516000000_add_referrals_privacy_required_announcements.sql b/crates/aether-data/migrations/postgres/20260516000000_add_referrals_privacy_required_announcements.sql new file mode 100644 index 000000000..8225fd373 --- /dev/null +++ b/crates/aether-data/migrations/postgres/20260516000000_add_referrals_privacy_required_announcements.sql @@ -0,0 +1,57 @@ +ALTER TABLE public.users + ADD COLUMN privacy_policy_accepted_version character varying(64), + ADD COLUMN privacy_policy_accepted_at timestamp with time zone; + +ALTER TABLE public.announcements + ADD COLUMN requires_ack boolean NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS public.user_invite_codes ( + user_id character varying(64) PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, + invite_code character varying(64) NOT NULL UNIQUE, + active boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp with time zone NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_referrals ( + id character varying(64) PRIMARY KEY, + inviter_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + invitee_user_id character varying(64) NOT NULL UNIQUE REFERENCES public.users(id) ON DELETE CASCADE, + invite_code_snapshot character varying(64) NOT NULL, + source_json jsonb, + first_paid_order_id character varying(64) REFERENCES public.payment_orders(id) ON DELETE SET NULL, + first_paid_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp with time zone NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter + ON public.user_referrals USING btree (inviter_user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code + ON public.user_referrals USING btree (invite_code_snapshot); + +CREATE TABLE IF NOT EXISTS public.referral_rewards ( + id character varying(64) PRIMARY KEY, + referral_id character varying(64) NOT NULL REFERENCES public.user_referrals(id) ON DELETE CASCADE, + inviter_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + invitee_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + reward_type character varying(32) NOT NULL, + trigger_point character varying(64) NOT NULL, + source_order_id character varying(64) REFERENCES public.payment_orders(id) ON DELETE SET NULL, + idempotency_key character varying(128) NOT NULL UNIQUE, + amount_usd numeric(20,8) NOT NULL, + status character varying(32) NOT NULL DEFAULT 'pending', + wallet_transaction_id character varying(64), + reversed_amount_usd numeric(20,8) NOT NULL DEFAULT 0, + pending_reversal_amount_usd numeric(20,8) NOT NULL DEFAULT 0, + failure_reason text, + admin_operator_id character varying(64), + admin_note text, + created_at timestamp with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp with time zone NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status + ON public.referral_rewards USING btree (inviter_user_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order + ON public.referral_rewards USING btree (source_order_id); diff --git a/crates/aether-data/migrations/sqlite/20260516000000_add_referrals_privacy_required_announcements.sql b/crates/aether-data/migrations/sqlite/20260516000000_add_referrals_privacy_required_announcements.sql new file mode 100644 index 000000000..d0f72a569 --- /dev/null +++ b/crates/aether-data/migrations/sqlite/20260516000000_add_referrals_privacy_required_announcements.sql @@ -0,0 +1,63 @@ +ALTER TABLE users ADD COLUMN privacy_policy_accepted_version TEXT; +ALTER TABLE users ADD COLUMN privacy_policy_accepted_at INTEGER; + +ALTER TABLE announcements ADD COLUMN requires_ack INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS user_invite_codes ( + user_id TEXT PRIMARY KEY, + invite_code TEXT NOT NULL UNIQUE, + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_referrals ( + id TEXT PRIMARY KEY, + inviter_user_id TEXT NOT NULL, + invitee_user_id TEXT NOT NULL UNIQUE, + invite_code_snapshot TEXT NOT NULL, + source_json TEXT, + first_paid_order_id TEXT, + first_paid_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(inviter_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(invitee_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(first_paid_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter + ON user_referrals (inviter_user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code + ON user_referrals (invite_code_snapshot); + +CREATE TABLE IF NOT EXISTS referral_rewards ( + id TEXT PRIMARY KEY, + referral_id TEXT NOT NULL, + inviter_user_id TEXT NOT NULL, + invitee_user_id TEXT NOT NULL, + reward_type TEXT NOT NULL, + trigger_point TEXT NOT NULL, + source_order_id TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + amount_usd REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + wallet_transaction_id TEXT, + reversed_amount_usd REAL NOT NULL DEFAULT 0, + pending_reversal_amount_usd REAL NOT NULL DEFAULT 0, + failure_reason TEXT, + admin_operator_id TEXT, + admin_note TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(referral_id) REFERENCES user_referrals(id) ON DELETE CASCADE, + FOREIGN KEY(inviter_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(invitee_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(source_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status + ON referral_rewards (inviter_user_id, status, created_at); +CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order + ON referral_rewards (source_order_id); diff --git a/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql b/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql index c6427caea..5295bd070 100644 --- a/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql +++ b/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql @@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS public.announcements ( author_id character varying(36), is_active boolean DEFAULT true, is_pinned boolean DEFAULT false, + requires_ack boolean DEFAULT false NOT NULL, start_time timestamp with time zone, end_time timestamp with time zone, created_at timestamp with time zone DEFAULT now() NOT NULL, @@ -1379,11 +1380,72 @@ CREATE TABLE IF NOT EXISTS public.users ( email_verified boolean NOT NULL, rate_limit integer, rate_limit_mode text DEFAULT 'system'::text NOT NULL, + privacy_policy_accepted_version character varying(64), + privacy_policy_accepted_at timestamp with time zone, metadata json ); +-- +-- Name: user_invite_codes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE IF NOT EXISTS public.user_invite_codes ( + user_id character varying(64) NOT NULL, + invite_code character varying(64) NOT NULL, + active boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + +-- +-- Name: user_referrals; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE IF NOT EXISTS public.user_referrals ( + id character varying(64) NOT NULL, + inviter_user_id character varying(64) NOT NULL, + invitee_user_id character varying(64) NOT NULL, + invite_code_snapshot character varying(64) NOT NULL, + source_json jsonb, + first_paid_order_id character varying(64), + first_paid_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + +-- +-- Name: referral_rewards; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE IF NOT EXISTS public.referral_rewards ( + id character varying(64) NOT NULL, + referral_id character varying(64) NOT NULL, + inviter_user_id character varying(64) NOT NULL, + invitee_user_id character varying(64) NOT NULL, + reward_type character varying(32) NOT NULL, + trigger_point character varying(64) NOT NULL, + source_order_id character varying(64), + idempotency_key character varying(128) NOT NULL, + amount_usd numeric(20,8) NOT NULL, + status character varying(32) DEFAULT 'pending'::character varying NOT NULL, + wallet_transaction_id character varying(64), + reversed_amount_usd numeric(20,8) DEFAULT 0 NOT NULL, + pending_reversal_amount_usd numeric(20,8) DEFAULT 0 NOT NULL, + failure_reason text, + admin_operator_id character varying(64), + admin_note text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + -- -- Name: user_groups; Type: TABLE; Schema: public; Owner: - -- diff --git a/crates/aether-data/schema/bootstrap/postgres/003_constraints.sql b/crates/aether-data/schema/bootstrap/postgres/003_constraints.sql index b57ec5ee8..8975f3bbf 100644 --- a/crates/aether-data/schema/bootstrap/postgres/003_constraints.sql +++ b/crates/aether-data/schema/bootstrap/postgres/003_constraints.sql @@ -1227,6 +1227,96 @@ END $mig$; +-- +-- Name: user_invite_codes user_invite_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_invite_codes + ADD CONSTRAINT user_invite_codes_pkey PRIMARY KEY (user_id); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: user_invite_codes user_invite_codes_invite_code_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_invite_codes + ADD CONSTRAINT user_invite_codes_invite_code_key UNIQUE (invite_code); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: user_referrals user_referrals_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_referrals + ADD CONSTRAINT user_referrals_pkey PRIMARY KEY (id); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: user_referrals user_referrals_invitee_user_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_referrals + ADD CONSTRAINT user_referrals_invitee_user_id_key UNIQUE (invitee_user_id); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: referral_rewards referral_rewards_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.referral_rewards + ADD CONSTRAINT referral_rewards_pkey PRIMARY KEY (id); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: referral_rewards referral_rewards_idempotency_key_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.referral_rewards + ADD CONSTRAINT referral_rewards_idempotency_key_key UNIQUE (idempotency_key); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + -- -- Name: user_plan_entitlements user_plan_entitlements_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- diff --git a/crates/aether-data/schema/bootstrap/postgres/004_indexes.sql b/crates/aether-data/schema/bootstrap/postgres/004_indexes.sql index 64b45c2f2..851f7665e 100644 --- a/crates/aether-data/schema/bootstrap/postgres/004_indexes.sql +++ b/crates/aether-data/schema/bootstrap/postgres/004_indexes.sql @@ -581,6 +581,38 @@ CREATE INDEX IF NOT EXISTS idx_user_sessions_user_device ON public.user_sessions +-- +-- Name: idx_user_referrals_inviter; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter ON public.user_referrals USING btree (inviter_user_id, created_at DESC); + + + +-- +-- Name: idx_user_referrals_invite_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code ON public.user_referrals USING btree (invite_code_snapshot); + + + +-- +-- Name: idx_referral_rewards_inviter_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status ON public.referral_rewards USING btree (inviter_user_id, status, created_at DESC); + + + +-- +-- Name: idx_referral_rewards_source_order; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order ON public.referral_rewards USING btree (source_order_id); + + + -- -- Name: idx_video_tasks_external_id; Type: INDEX; Schema: public; Owner: - -- diff --git a/crates/aether-data/schema/bootstrap/postgres/005_foreign_keys.sql b/crates/aether-data/schema/bootstrap/postgres/005_foreign_keys.sql index cefe02033..a1691561d 100644 --- a/crates/aether-data/schema/bootstrap/postgres/005_foreign_keys.sql +++ b/crates/aether-data/schema/bootstrap/postgres/005_foreign_keys.sql @@ -792,6 +792,126 @@ END $mig$; +-- +-- Name: user_invite_codes user_invite_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_invite_codes + ADD CONSTRAINT user_invite_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: user_referrals user_referrals_inviter_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_referrals + ADD CONSTRAINT user_referrals_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES public.users(id) ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: user_referrals user_referrals_invitee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_referrals + ADD CONSTRAINT user_referrals_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES public.users(id) ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: user_referrals user_referrals_first_paid_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.user_referrals + ADD CONSTRAINT user_referrals_first_paid_order_id_fkey FOREIGN KEY (first_paid_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: referral_rewards referral_rewards_referral_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.referral_rewards + ADD CONSTRAINT referral_rewards_referral_id_fkey FOREIGN KEY (referral_id) REFERENCES public.user_referrals(id) ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: referral_rewards referral_rewards_inviter_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.referral_rewards + ADD CONSTRAINT referral_rewards_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES public.users(id) ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: referral_rewards referral_rewards_invitee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.referral_rewards + ADD CONSTRAINT referral_rewards_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES public.users(id) ON DELETE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + +-- +-- Name: referral_rewards referral_rewards_source_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +DO $mig$ BEGIN + ALTER TABLE ONLY public.referral_rewards + ADD CONSTRAINT referral_rewards_source_order_id_fkey FOREIGN KEY (source_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL; +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; + WHEN invalid_table_definition THEN NULL; +END $mig$; + + + -- -- Name: video_tasks video_tasks_api_key_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/crates/aether-data/src/lifecycle/bootstrap/postgres.rs b/crates/aether-data/src/lifecycle/bootstrap/postgres.rs index d8c34abad..2c239bab0 100644 --- a/crates/aether-data/src/lifecycle/bootstrap/postgres.rs +++ b/crates/aether-data/src/lifecycle/bootstrap/postgres.rs @@ -7,7 +7,7 @@ use tracing::info; // Generated by build.rs from schema/bootstrap/postgres. pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str = include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql")); -pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260515000000; +pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260516000000; const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#" SELECT COUNT(*)::BIGINT diff --git a/crates/aether-data/src/lifecycle/migrate/tests.rs b/crates/aether-data/src/lifecycle/migrate/tests.rs index 6954d88d4..b34b70261 100644 --- a/crates/aether-data/src/lifecycle/migrate/tests.rs +++ b/crates/aether-data/src/lifecycle/migrate/tests.rs @@ -305,6 +305,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() { 20260512090000, 20260512110000, 20260515000000, + 20260516000000, ] ); } @@ -587,6 +588,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() { 20260512000000, 20260512090000, 20260512110000, + 20260516000000, ] ); assert_eq!( @@ -603,6 +605,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() { 20260512000000, 20260512090000, 20260512110000, + 20260516000000, ] ); } @@ -1119,6 +1122,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() { 20260512090000, 20260512110000, 20260515000000, + 20260516000000, ] ); } diff --git a/crates/aether-data/src/repository/announcements/memory.rs b/crates/aether-data/src/repository/announcements/memory.rs index 9a35ef504..87b5213d8 100644 --- a/crates/aether-data/src/repository/announcements/memory.rs +++ b/crates/aether-data/src/repository/announcements/memory.rs @@ -135,6 +135,48 @@ impl AnnouncementReadRepository for InMemoryAnnouncementReadRepository { Ok(total) } + + async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, DataLayerError> { + let announcements = self + .announcements + .read() + .expect("announcement repository lock"); + let reads = self + .announcement_reads + .read() + .expect("announcement reads repository lock"); + + let mut items = announcements + .iter() + .filter(|announcement| { + announcement.requires_ack + && announcement.is_active + && announcement + .start_time_unix_secs + .is_none_or(|value| value <= now_unix_secs) + && announcement + .end_time_unix_secs + .is_none_or(|value| value >= now_unix_secs) + && !reads.contains(&(user_id.to_string(), announcement.id.clone())) + }) + .cloned() + .collect::>(); + items.sort_by(|left, right| { + right + .is_pinned + .cmp(&left.is_pinned) + .then_with(|| right.priority.cmp(&left.priority)) + .then_with(|| right.created_at_unix_ms.cmp(&left.created_at_unix_ms)) + .then_with(|| left.id.cmp(&right.id)) + }); + items.truncate(limit); + Ok(items) + } } #[async_trait] @@ -153,6 +195,7 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository { record.priority, true, record.is_pinned, + record.requires_ack, Some(record.author_id), None, record.start_time_unix_secs.map(|value| value as i64), @@ -201,6 +244,9 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository { if let Some(is_pinned) = record.is_pinned { announcement.is_pinned = is_pinned; } + if let Some(requires_ack) = record.requires_ack { + announcement.requires_ack = requires_ack; + } if let Some(start_time_unix_secs) = record.start_time_unix_secs { announcement.start_time_unix_secs = Some(start_time_unix_secs); } @@ -261,6 +307,7 @@ mod tests { 10, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -291,6 +338,7 @@ mod tests { kind: "maintenance".to_string(), priority: 10, is_pinned: true, + requires_ack: false, author_id: "admin-1".to_string(), start_time_unix_secs: None, end_time_unix_secs: None, @@ -308,6 +356,7 @@ mod tests { priority: Some(99), is_active: Some(false), is_pinned: Some(false), + requires_ack: Some(true), start_time_unix_secs: None, end_time_unix_secs: None, }) @@ -337,6 +386,7 @@ mod tests { 10, true, true, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, @@ -353,6 +403,7 @@ mod tests { 5, true, false, + false, Some("admin-1".to_string()), Some("admin".to_string()), None, diff --git a/crates/aether-data/src/repository/announcements/mysql.rs b/crates/aether-data/src/repository/announcements/mysql.rs index 3d7df7d07..757eafb05 100644 --- a/crates/aether-data/src/repository/announcements/mysql.rs +++ b/crates/aether-data/src/repository/announcements/mysql.rs @@ -18,6 +18,7 @@ SELECT a.priority, a.is_active, a.is_pinned, + a.requires_ack, a.author_id, u.username AS author_username, a.start_time AS start_time_unix_secs, @@ -144,6 +145,39 @@ WHERE a.is_active = 1 .map_sql_err()?; Ok(row.try_get::("total").map_sql_err()?.max(0) as u64) } + + async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, DataLayerError> { + let rows = sqlx::query(&format!( + r#" +{ANNOUNCEMENT_SELECT} +WHERE a.is_active = 1 + AND a.requires_ack = 1 + AND (a.start_time IS NULL OR a.start_time <= ?) + AND (a.end_time IS NULL OR a.end_time >= ?) + AND NOT EXISTS ( + SELECT 1 + FROM announcement_reads r + WHERE r.user_id = ? + AND r.announcement_id = a.id + ) +ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC +LIMIT ? +"# + )) + .bind(now_unix_secs as i64) + .bind(now_unix_secs as i64) + .bind(user_id) + .bind(limit as i64) + .fetch_all(&self.pool) + .await + .map_sql_err()?; + rows.iter().map(map_announcement_row).collect() + } } #[async_trait] @@ -159,9 +193,9 @@ impl AnnouncementWriteRepository for MysqlAnnouncementRepository { r#" INSERT INTO announcements ( id, title, content, `type`, priority, author_id, is_active, is_pinned, - start_time, end_time, created_at, updated_at + requires_ack, start_time, end_time, created_at, updated_at ) -VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) "#, ) .bind(&id) @@ -171,6 +205,7 @@ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) .bind(record.priority) .bind(record.author_id) .bind(record.is_pinned) + .bind(record.requires_ack) .bind(optional_i64_from_u64( record.start_time_unix_secs, "announcements.start_time", @@ -204,6 +239,7 @@ SET title = COALESCE(?, title), priority = COALESCE(?, priority), is_active = COALESCE(?, is_active), is_pinned = COALESCE(?, is_pinned), + requires_ack = COALESCE(?, requires_ack), start_time = COALESCE(?, start_time), end_time = COALESCE(?, end_time), updated_at = ? @@ -216,6 +252,7 @@ WHERE id = ? .bind(record.priority) .bind(record.is_active) .bind(record.is_pinned) + .bind(record.requires_ack) .bind(optional_i64_from_u64( record.start_time_unix_secs, "announcements.start_time", @@ -303,6 +340,7 @@ fn map_announcement_row(row: &MySqlRow) -> Result= TO_TIMESTAMP($2::double precision)) + AND NOT EXISTS ( + SELECT 1 + FROM announcement_reads r + WHERE r.user_id = $1 + AND r.announcement_id = a.id + ) +ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC +LIMIT $3 +"#; + const CREATE_ANNOUNCEMENT_SQL: &str = r#" INSERT INTO announcements ( id, @@ -95,6 +129,7 @@ INSERT INTO announcements ( author_id, is_active, is_pinned, + requires_ack, start_time, end_time, created_at, @@ -111,6 +146,7 @@ VALUES ( $7, $8, $9, + $10, NOW(), NOW() ) @@ -122,6 +158,7 @@ RETURNING priority, is_active, is_pinned, + requires_ack, author_id, (SELECT username FROM users WHERE id = announcements.author_id) AS author_username, EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs, @@ -139,8 +176,9 @@ SET priority = COALESCE($5, priority), is_active = COALESCE($6, is_active), is_pinned = COALESCE($7, is_pinned), - start_time = COALESCE($8, start_time), - end_time = COALESCE($9, end_time), + requires_ack = COALESCE($8, requires_ack), + start_time = COALESCE($9, start_time), + end_time = COALESCE($10, end_time), updated_at = NOW() WHERE id = $1 RETURNING @@ -151,6 +189,7 @@ RETURNING priority, is_active, is_pinned, + requires_ack, author_id, (SELECT username FROM users WHERE id = announcements.author_id) AS author_username, EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs, @@ -252,6 +291,24 @@ impl AnnouncementReadRepository for SqlxAnnouncementReadRepository { .map_postgres_err()?; Ok(row.try_get::("total").map_postgres_err()?.max(0) as u64) } + + async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, DataLayerError> { + let mut rows = sqlx::query(LIST_REQUIRED_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL) + .bind(user_id) + .bind(now_unix_secs as f64) + .bind(limit as i64) + .fetch(&self.pool); + let mut items = Vec::new(); + while let Some(row) = rows.try_next().await.map_postgres_err()? { + items.push(map_announcement_row(&row)?); + } + Ok(items) + } } #[async_trait] @@ -269,6 +326,7 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository { .bind(record.priority) .bind(record.author_id) .bind(record.is_pinned) + .bind(record.requires_ack) .bind(optional_datetime(record.start_time_unix_secs)) .bind(optional_datetime(record.end_time_unix_secs)) .fetch_one(&self.pool) @@ -290,6 +348,7 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository { .bind(record.priority) .bind(record.is_active) .bind(record.is_pinned) + .bind(record.requires_ack) .bind(optional_datetime(record.start_time_unix_secs)) .bind(optional_datetime(record.end_time_unix_secs)) .fetch_optional(&self.pool) @@ -356,6 +415,7 @@ fn map_announcement_row(row: &PgRow) -> Result("total").map_sql_err()?.max(0) as u64) } + + async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, DataLayerError> { + let rows = sqlx::query(&format!( + r#" +{ANNOUNCEMENT_SELECT} +WHERE a.is_active = 1 + AND a.requires_ack = 1 + AND (a.start_time IS NULL OR a.start_time <= ?) + AND (a.end_time IS NULL OR a.end_time >= ?) + AND NOT EXISTS ( + SELECT 1 + FROM announcement_reads r + WHERE r.user_id = ? + AND r.announcement_id = a.id + ) +ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC +LIMIT ? +"# + )) + .bind(now_unix_secs as i64) + .bind(now_unix_secs as i64) + .bind(user_id) + .bind(limit as i64) + .fetch_all(&self.pool) + .await + .map_sql_err()?; + rows.iter().map(map_announcement_row).collect() + } } #[async_trait] @@ -159,9 +193,9 @@ impl AnnouncementWriteRepository for SqliteAnnouncementRepository { r#" INSERT INTO announcements ( id, title, content, type, priority, author_id, is_active, is_pinned, - start_time, end_time, created_at, updated_at + requires_ack, start_time, end_time, created_at, updated_at ) -VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) "#, ) .bind(&id) @@ -171,6 +205,7 @@ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) .bind(record.priority) .bind(record.author_id) .bind(record.is_pinned) + .bind(record.requires_ack) .bind(optional_i64_from_u64( record.start_time_unix_secs, "announcements.start_time", @@ -204,6 +239,7 @@ SET title = COALESCE(?, title), priority = COALESCE(?, priority), is_active = COALESCE(?, is_active), is_pinned = COALESCE(?, is_pinned), + requires_ack = COALESCE(?, requires_ack), start_time = COALESCE(?, start_time), end_time = COALESCE(?, end_time), updated_at = ? @@ -216,6 +252,7 @@ WHERE id = ? .bind(record.priority) .bind(record.is_active) .bind(record.is_pinned) + .bind(record.requires_ack) .bind(optional_i64_from_u64( record.start_time_unix_secs, "announcements.start_time", @@ -303,6 +340,7 @@ fn map_announcement_row(row: &SqliteRow) -> Result, pub author_username: Option, pub start_time_unix_secs: Option, @@ -27,6 +28,7 @@ impl StoredAnnouncement { priority: i32, is_active: bool, is_pinned: bool, + requires_ack: bool, author_id: Option, author_username: Option, start_time_unix_secs: Option, @@ -63,6 +65,7 @@ impl StoredAnnouncement { priority, is_active, is_pinned, + requires_ack, author_id, author_username, start_time_unix_secs: start_time_unix_secs @@ -117,6 +120,13 @@ pub trait AnnouncementReadRepository: Send + Sync { user_id: &str, now_unix_secs: u64, ) -> Result; + + async fn list_required_unread_active_announcements( + &self, + user_id: &str, + now_unix_secs: u64, + limit: usize, + ) -> Result, crate::DataLayerError>; } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -126,6 +136,7 @@ pub struct CreateAnnouncementRecord { pub kind: String, pub priority: i32, pub is_pinned: bool, + pub requires_ack: bool, pub author_id: String, pub start_time_unix_secs: Option, pub end_time_unix_secs: Option, @@ -166,6 +177,7 @@ pub struct UpdateAnnouncementRecord { pub priority: Option, pub is_active: Option, pub is_pinned: Option, + pub requires_ack: Option, pub start_time_unix_secs: Option, pub end_time_unix_secs: Option, } diff --git a/frontend/src/api/announcements.ts b/frontend/src/api/announcements.ts index ff8b6ff91..56bc5c921 100644 --- a/frontend/src/api/announcements.ts +++ b/frontend/src/api/announcements.ts @@ -8,6 +8,7 @@ export interface Announcement { priority: number is_pinned: boolean is_active: boolean + requires_ack: boolean author: { id: string // UUID username: string @@ -31,6 +32,7 @@ export interface CreateAnnouncementRequest { type?: 'info' | 'warning' | 'maintenance' | 'important' priority?: number is_pinned?: boolean + requires_ack?: boolean start_time?: string end_time?: string } @@ -42,6 +44,7 @@ export interface UpdateAnnouncementRequest { priority?: number is_active?: boolean is_pinned?: boolean + requires_ack?: boolean start_time?: string end_time?: string } @@ -88,6 +91,11 @@ export const announcementApi = { return response.data }, + async getRequiredUnreadAnnouncements(): Promise { + const response = await apiClient.get('/api/announcements/users/me/required-unread') + return response.data + }, + // 管理员方法 // 创建公告 async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> { @@ -106,4 +114,4 @@ export const announcementApi = { const response = await apiClient.delete(`/api/announcements/${id}`) return response.data } -} \ No newline at end of file +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 7b096e98d..3eb111a5a 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -69,6 +69,9 @@ export interface RegisterRequest { username: string password: string turnstile_token?: string + invite_code?: string + privacy_policy_accepted?: boolean + privacy_policy_version?: string } export interface RegisterResponse { @@ -86,6 +89,14 @@ export interface RegistrationSettingsResponse { turnstile_enabled?: boolean turnstile_site_key?: string | null turnstile_required_actions?: string[] + privacy_policy?: RegistrationPrivacyPolicySettings +} + +export interface RegistrationPrivacyPolicySettings { + enabled: boolean + format: 'markdown' | 'html' + content: string + version: string } export interface AuthSettingsResponse { diff --git a/frontend/src/api/referrals.ts b/frontend/src/api/referrals.ts new file mode 100644 index 000000000..e1ce472ff --- /dev/null +++ b/frontend/src/api/referrals.ts @@ -0,0 +1,114 @@ +import apiClient from './client' + +export interface ReferralSummary { + total_invites: number + effective_invites: number + paid_reward_usd: number + pending_reward_usd: number + reversed_reward_usd: number +} + +export interface ReferralDashboardResponse { + invite_code: string + invitation_link: string + summary: ReferralSummary +} + +export interface ReferralRelationshipRecord { + id: string + inviter_user_id: string + inviter_username?: string | null + invitee_user_id: string + invitee_username?: string | null + invite_code_snapshot: string + first_paid_order_id?: string | null + first_paid_at_unix_secs?: number | null + source?: Record | null + created_at_unix_secs: number +} + +export interface ReferralRewardRecord { + id: string + referral_id: string + inviter_user_id: string + invitee_user_id: string + reward_type: string + source_order_id?: string | null + trigger_point: string + amount_usd: number + status: string + wallet_transaction_id?: string | null + idempotency_key: string + reversed_amount_usd: number + pending_reversal_amount_usd: number + admin_operator_id?: string | null + admin_note?: string | null + created_at_unix_secs: number + updated_at_unix_secs: number +} + +export interface ReferralListResponse { + items: T[] + total: number + limit: number + offset: number + stats: ReferralSummary +} + +export interface ReferralRelationshipQuery { + inviter?: string + invitee?: string + invite_code?: string + first_paid?: boolean | null + limit?: number + offset?: number +} + +export interface ReferralRewardQuery { + order_id?: string + reward_type?: string + status?: string + limit?: number + offset?: number +} + +function cleanParams>(params: T): Partial { + return Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '') + ) as Partial +} + +export const referralApi = { + async getMyReferral(): Promise { + const response = await apiClient.get('/api/users/me/referral') + return response.data + }, + + async getAdminReferrals( + params: ReferralRelationshipQuery = {} + ): Promise> { + const response = await apiClient.get('/api/admin/referrals', { + params: cleanParams(params as Record) + }) + return response.data + }, + + async getAdminReferralRewards( + params: ReferralRewardQuery = {} + ): Promise> { + const response = await apiClient.get('/api/admin/referral-rewards', { + params: cleanParams(params as Record) + }) + return response.data + }, + + async retryReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> { + const response = await apiClient.post(`/api/admin/referral-rewards/${id}/retry`, { note }) + return response.data + }, + + async voidReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> { + const response = await apiClient.post(`/api/admin/referral-rewards/${id}/void`, { note }) + return response.data + } +} diff --git a/frontend/src/features/auth/components/LoginDialog.vue b/frontend/src/features/auth/components/LoginDialog.vue index 44615c192..dbdf1e1e9 100644 --- a/frontend/src/features/auth/components/LoginDialog.vue +++ b/frontend/src/features/auth/components/LoginDialog.vue @@ -229,6 +229,7 @@ :password-policy-level="passwordPolicyLevel" :turnstile-enabled="turnstileEnabled" :turnstile-site-key="turnstileSiteKey" + :privacy-policy="privacyPolicy" @success="handleRegisterSuccess" @switch-to-login="handleSwitchToLogin" /> @@ -236,7 +237,7 @@ diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue index a1b720c7b..d20a6d6e1 100644 --- a/frontend/src/views/admin/SystemSettings.vue +++ b/frontend/src/views/admin/SystemSettings.vue @@ -58,6 +58,15 @@ :turnstile-secret-key="systemConfig.turnstile_secret_key" :turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set" :turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr" + :referral-enabled="systemConfig.referral_enabled" + :referral-reward-mode="systemConfig.referral_reward_mode" + :referral-recharge-percent="systemConfig.referral_recharge_percent" + :referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd" + :referral-headcount-trigger="systemConfig.referral_headcount_trigger" + :registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled" + :registration-privacy-policy-format="systemConfig.registration_privacy_policy_format" + :registration-privacy-policy-content="systemConfig.registration_privacy_policy_content" + :registration-privacy-policy-version="systemConfig.registration_privacy_policy_version" :auto-delete-expired-keys="systemConfig.auto_delete_expired_keys" :enable-format-conversion="systemConfig.enable_format_conversion" :enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat" @@ -73,6 +82,15 @@ @update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event" @update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event" @clear-turnstile-secret="clearTurnstileSecret" + @update:referral-enabled="systemConfig.referral_enabled = $event" + @update:referral-reward-mode="systemConfig.referral_reward_mode = $event" + @update:referral-recharge-percent="systemConfig.referral_recharge_percent = $event" + @update:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd = $event" + @update:referral-headcount-trigger="systemConfig.referral_headcount_trigger = $event" + @update:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled = $event" + @update:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format = $event" + @update:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content = $event" + @update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event" @update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event" @update:enable-format-conversion="systemConfig.enable_format_conversion = $event" @update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event" diff --git a/frontend/src/views/admin/system-settings/BasicConfigSection.vue b/frontend/src/views/admin/system-settings/BasicConfigSection.vue index 71cadda90..d19330b04 100644 --- a/frontend/src/views/admin/system-settings/BasicConfigSection.vue +++ b/frontend/src/views/admin/system-settings/BasicConfigSection.vue @@ -262,6 +262,203 @@

+ +
+
+
+ +
+ +

+ 开启后可按充值比例、人头或两者同时发放赠款返利 +

+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ +
+ +

+ 开启后注册时必须确认当前版本 +

+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +