feat(referrals): 添加邀请返利和注册确认功能

This commit is contained in:
Entropy.Xu
2026-05-16 17:41:52 +08:00
parent 328ac721ce
commit 973eb1a614
56 changed files with 6246 additions and 52 deletions
@@ -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>,