mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 04:09:48 +08:00
feat(referrals): 添加邀请返利和注册确认功能
This commit is contained in:
@@ -32,6 +32,7 @@ struct AdminAnnouncementCreateRequest {
|
||||
kind: String,
|
||||
priority: Option<i32>,
|
||||
is_pinned: Option<bool>,
|
||||
requires_ack: Option<bool>,
|
||||
start_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
}
|
||||
@@ -45,6 +46,7 @@ struct AdminAnnouncementUpdateRequest {
|
||||
priority: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
is_pinned: Option<bool>,
|
||||
requires_ack: Option<bool>,
|
||||
start_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
Some(
|
||||
Json(json!({
|
||||
"items": payload_items,
|
||||
"total": payload_items.len(),
|
||||
}))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
Some("read_all")
|
||||
if request_context.request_method == http::Method::POST
|
||||
&& matches!(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ struct AuthRegisterRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
turnstile_token: Option<String>,
|
||||
invite_code: Option<String>,
|
||||
privacy_policy_accepted: Option<bool>,
|
||||
privacy_policy_version: Option<String>,
|
||||
}
|
||||
|
||||
#[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<RegistrationPrivacyPolicySettings, GatewayError> {
|
||||
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<String, GatewayError> {
|
||||
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() {
|
||||
|
||||
@@ -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 {
|
||||
..
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<Body> {
|
||||
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()
|
||||
}
|
||||
@@ -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" =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user