mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
feat(referrals): 添加邀请返利和注册确认功能
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
@@ -6,6 +6,7 @@ pub(super) mod features;
|
||||
mod model;
|
||||
pub(super) mod observability;
|
||||
pub(super) mod provider;
|
||||
mod referrals;
|
||||
mod system;
|
||||
mod users;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail.into() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn build_admin_referrals_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": "Admin referral data unavailable" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn parse_limit(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "limit") {
|
||||
Some(value) => value
|
||||
.parse::<usize>()
|
||||
.map(|value| value.clamp(1, 200))
|
||||
.map_err(|_| "limit 必须是正整数".to_string()),
|
||||
None => Ok(50),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_offset(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "offset") {
|
||||
Some(value) => value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "offset 必须是非负整数".to_string()),
|
||||
None => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_bool(query: Option<&str>, key: &str) -> Result<Option<bool>, 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<String> {
|
||||
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<String> {
|
||||
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<Option<String>, String> {
|
||||
let Some(body) = body.filter(|body| !body.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload = serde_json::from_slice::<ReferralAdminMutationRequest>(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<Response<Body>, 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<Response<Body>, 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<Response<Body>, 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<Response<Body>, 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()),
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<Option<Response<Body>>, GatewayError> {
|
||||
let next_user_ids = next_user_ids.iter().cloned().collect::<BTreeSet<String>>();
|
||||
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::<Vec<_>>();
|
||||
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::<BTreeSet<_>>();
|
||||
|
||||
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>,
|
||||
|
||||
Reference in New Issue
Block a user