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::
+
+
+
+
+
+
+
+
+ 开启后可按充值比例、人头或两者同时发放赠款返利
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 开启后注册时必须确认当前版本
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -270,6 +467,7 @@
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
+import Textarea from '@/components/ui/textarea.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
@@ -288,6 +486,15 @@ defineProps<{
turnstileSecretKey: string
turnstileSecretConfigured: boolean
turnstileAllowedHostnamesStr: string
+ referralEnabled: boolean
+ referralRewardMode: string
+ referralRechargePercent: number
+ referralHeadcountAmountUsd: number
+ referralHeadcountTrigger: string
+ registrationPrivacyPolicyEnabled: boolean
+ registrationPrivacyPolicyFormat: string
+ registrationPrivacyPolicyContent: string
+ registrationPrivacyPolicyVersion: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
@@ -306,6 +513,15 @@ defineEmits<{
'update:turnstileSecretKey': [value: string]
'update:turnstileAllowedHostnamesStr': [value: string]
clearTurnstileSecret: []
+ 'update:referralEnabled': [value: boolean]
+ 'update:referralRewardMode': [value: string]
+ 'update:referralRechargePercent': [value: number]
+ 'update:referralHeadcountAmountUsd': [value: number]
+ 'update:referralHeadcountTrigger': [value: string]
+ 'update:registrationPrivacyPolicyEnabled': [value: boolean]
+ 'update:registrationPrivacyPolicyFormat': [value: string]
+ 'update:registrationPrivacyPolicyContent': [value: string]
+ 'update:registrationPrivacyPolicyVersion': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
diff --git a/frontend/src/views/admin/system-settings/composables/useSystemConfig.ts b/frontend/src/views/admin/system-settings/composables/useSystemConfig.ts
index ec7def75c..3652667b8 100644
--- a/frontend/src/views/admin/system-settings/composables/useSystemConfig.ts
+++ b/frontend/src/views/admin/system-settings/composables/useSystemConfig.ts
@@ -20,6 +20,15 @@ export interface SystemConfig {
turnstile_secret_key: string
turnstile_secret_key_is_set: boolean
turnstile_allowed_hostnames: string[]
+ referral_enabled: boolean
+ referral_reward_mode: string
+ referral_recharge_percent: number
+ referral_headcount_amount_usd: number
+ referral_headcount_trigger: string
+ registration_privacy_policy_enabled: boolean
+ registration_privacy_policy_format: string
+ registration_privacy_policy_content: string
+ registration_privacy_policy_version: string
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
@@ -65,6 +74,15 @@ const CONFIG_KEYS = [
'turnstile_site_key',
'turnstile_secret_key',
'turnstile_allowed_hostnames',
+ 'referral_enabled',
+ 'referral_reward_mode',
+ 'referral_recharge_percent',
+ 'referral_headcount_amount_usd',
+ 'referral_headcount_trigger',
+ 'registration_privacy_policy_enabled',
+ 'registration_privacy_policy_format',
+ 'registration_privacy_policy_content',
+ 'registration_privacy_policy_version',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
@@ -112,6 +130,15 @@ function createDefaultConfig(): SystemConfig {
turnstile_secret_key: '',
turnstile_secret_key_is_set: false,
turnstile_allowed_hostnames: [],
+ referral_enabled: false,
+ referral_reward_mode: 'percent',
+ referral_recharge_percent: 5,
+ referral_headcount_amount_usd: 0,
+ referral_headcount_trigger: 'registration',
+ registration_privacy_policy_enabled: false,
+ registration_privacy_policy_format: 'markdown',
+ registration_privacy_policy_content: '',
+ registration_privacy_policy_version: '1',
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
@@ -184,6 +211,19 @@ export function useSystemConfig() {
systemConfig.value.turnstile_secret_key.trim() !== '' ||
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
+ systemConfig.value.referral_enabled !== originalConfig.value.referral_enabled ||
+ systemConfig.value.referral_reward_mode !== originalConfig.value.referral_reward_mode ||
+ systemConfig.value.referral_recharge_percent !== originalConfig.value.referral_recharge_percent ||
+ systemConfig.value.referral_headcount_amount_usd !== originalConfig.value.referral_headcount_amount_usd ||
+ systemConfig.value.referral_headcount_trigger !== originalConfig.value.referral_headcount_trigger ||
+ systemConfig.value.registration_privacy_policy_enabled !==
+ originalConfig.value.registration_privacy_policy_enabled ||
+ systemConfig.value.registration_privacy_policy_format !==
+ originalConfig.value.registration_privacy_policy_format ||
+ systemConfig.value.registration_privacy_policy_content !==
+ originalConfig.value.registration_privacy_policy_content ||
+ systemConfig.value.registration_privacy_policy_version !==
+ originalConfig.value.registration_privacy_policy_version ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
@@ -386,6 +426,51 @@ export function useSystemConfig() {
value: systemConfig.value.turnstile_allowed_hostnames,
description: 'Cloudflare Turnstile 允许的 hostname 列表',
},
+ {
+ key: 'referral_enabled',
+ value: systemConfig.value.referral_enabled,
+ description: '邀请返利开关',
+ },
+ {
+ key: 'referral_reward_mode',
+ value: systemConfig.value.referral_reward_mode,
+ description: '邀请返利方式',
+ },
+ {
+ key: 'referral_recharge_percent',
+ value: systemConfig.value.referral_recharge_percent,
+ description: '邀请充值比例返利百分比',
+ },
+ {
+ key: 'referral_headcount_amount_usd',
+ value: systemConfig.value.referral_headcount_amount_usd,
+ description: '邀请人头返利金额(美元)',
+ },
+ {
+ key: 'referral_headcount_trigger',
+ value: systemConfig.value.referral_headcount_trigger,
+ description: '邀请人头返利触发时机',
+ },
+ {
+ key: 'registration_privacy_policy_enabled',
+ value: systemConfig.value.registration_privacy_policy_enabled,
+ description: '注册隐私政策确认开关',
+ },
+ {
+ key: 'registration_privacy_policy_format',
+ value: systemConfig.value.registration_privacy_policy_format,
+ description: '注册隐私政策内容格式',
+ },
+ {
+ key: 'registration_privacy_policy_content',
+ value: systemConfig.value.registration_privacy_policy_content,
+ description: '注册隐私政策内容',
+ },
+ {
+ key: 'registration_privacy_policy_version',
+ value: systemConfig.value.registration_privacy_policy_version,
+ description: '注册隐私政策版本',
+ },
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -426,6 +511,21 @@ export function useSystemConfig() {
originalConfig.value.turnstile_allowed_hostnames = [
...systemConfig.value.turnstile_allowed_hostnames,
]
+ originalConfig.value.referral_enabled = systemConfig.value.referral_enabled
+ originalConfig.value.referral_reward_mode = systemConfig.value.referral_reward_mode
+ originalConfig.value.referral_recharge_percent = systemConfig.value.referral_recharge_percent
+ originalConfig.value.referral_headcount_amount_usd =
+ systemConfig.value.referral_headcount_amount_usd
+ originalConfig.value.referral_headcount_trigger =
+ systemConfig.value.referral_headcount_trigger
+ originalConfig.value.registration_privacy_policy_enabled =
+ systemConfig.value.registration_privacy_policy_enabled
+ originalConfig.value.registration_privacy_policy_format =
+ systemConfig.value.registration_privacy_policy_format
+ originalConfig.value.registration_privacy_policy_content =
+ systemConfig.value.registration_privacy_policy_content
+ originalConfig.value.registration_privacy_policy_version =
+ systemConfig.value.registration_privacy_policy_version
if (turnstileSecret) {
systemConfig.value.turnstile_secret_key = ''
systemConfig.value.turnstile_secret_key_is_set = true
diff --git a/frontend/src/views/public/PrivacyPolicy.vue b/frontend/src/views/public/PrivacyPolicy.vue
new file mode 100644
index 000000000..199a890f7
--- /dev/null
+++ b/frontend/src/views/public/PrivacyPolicy.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+ {{ siteName }}
+
+
+ 隐私政策
+
+
+
+
+ 返回首页
+
+
+
+
+
+
+
+ 隐私政策
+
+
+ 当前版本:{{ policy.version || '1' }}
+
+
+
+
+ 正在加载...
+
+
+ {{ loadError }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/user/Announcements.vue b/frontend/src/views/user/Announcements.vue
index 5526d6da4..a4773019d 100644
--- a/frontend/src/views/user/Announcements.vue
+++ b/frontend/src/views/user/Announcements.vue
@@ -132,6 +132,13 @@
{{ announcement.title }}
+
+ 必读
+
{{ announcement.title }}
+
+ 必读
+
置顶公告
+
+
+
+
{
@@ -663,7 +690,8 @@ function openCreateDialog() {
type: 'info',
priority: 0,
is_pinned: false,
- is_active: true
+ is_active: true,
+ requires_ack: false
}
dialogOpen.value = true
}
@@ -676,7 +704,8 @@ function openEditDialog(announcement: Announcement) {
type: announcement.type,
priority: announcement.priority,
is_pinned: announcement.is_pinned,
- is_active: announcement.is_active
+ is_active: announcement.is_active,
+ requires_ack: !!announcement.requires_ack
}
dialogOpen.value = true
}
diff --git a/frontend/src/views/user/ReferralCenter.vue b/frontend/src/views/user/ReferralCenter.vue
new file mode 100644
index 000000000..41e9f0a12
--- /dev/null
+++ b/frontend/src/views/user/ReferralCenter.vue
@@ -0,0 +1,155 @@
+
+
+
+
+ 我的邀请
+
+
+ 分享邀请码后,符合规则的返利会进入赠款余额
+
+
+
+
+ 正在加载...
+
+
+
+
+
+
+ 总邀请
+
+
+ {{ dashboard.summary.total_invites }}
+
+
+
+
+ 有效邀请
+
+
+ {{ dashboard.summary.effective_invites }}
+
+
+
+
+ 已发返利
+
+
+ {{ formatUsd(dashboard.summary.paid_reward_usd) }}
+
+
+
+
+
+
+
+
+
+
+ {{ dashboard.invite_code }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 待发返利
+
+
+ {{ formatUsd(dashboard.summary.pending_reward_usd) }}
+
+
+
+
+ 已冲回返利
+
+
+ {{ formatUsd(dashboard.summary.reversed_reward_usd) }}
+
+
+
+
+
+
+ 邀请数据暂不可用
+
+
+
+
+