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

This commit is contained in:
Entropy.Xu
2026-05-16 17:37:58 +08:00
parent 328ac721ce
commit 973eb1a614
56 changed files with 6246 additions and 52 deletions

View File

@@ -8,6 +8,56 @@ pub(super) fn classify_admin_operations_family_route(
normalized_path_no_trailing: &str,
) -> Option<ClassifiedRoute> {
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/"

View File

@@ -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",
};

View File

@@ -1925,14 +1925,38 @@ fn resolve_effective_list_policy(
&aether_data::repository::users::StoredUserGroup,
) -> (&str, Option<Vec<String>>),
) -> Option<Vec<String>> {
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<Vec<String>>),
) -> Option<Vec<String>> {
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<Vec<String>>) -> Option<Vec<String>> {
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]

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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<Vec<StoredAnnouncement>, 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,

View File

@@ -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),

View File

@@ -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),
}))

View File

@@ -6,6 +6,7 @@ pub(super) mod features;
mod model;
pub(super) mod observability;
pub(super) mod provider;
mod referrals;
mod system;
mod users;

View File

@@ -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()),
}
}

View File

@@ -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));
}

View File

@@ -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>,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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!(

View File

@@ -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,
},
}))
}

View File

@@ -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() {

View File

@@ -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 {
..
},

View File

@@ -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,
)
}
}
}

View File

@@ -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::*;

View File

@@ -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()
}

View File

@@ -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" =>
{

View File

@@ -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<Vec<aether_data::repository::announcements::StoredAnnouncement>, 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,

View File

@@ -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(())

View File

@@ -16,6 +16,7 @@ mod candidate_queries;
mod gemini_files;
mod monitoring;
mod payments;
mod referrals;
mod security;
mod usage_queries;
mod user_preferences;

View File

@@ -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<String> {
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::<f64>().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<bool, GatewayError> {
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<Option<ReferralRewardConfig>, 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<serde_json::Value>,
) -> 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<Option<ReferralUserDashboard>, 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<ReferralRelationshipRecord>,
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<ReferralRewardRecord>,
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<Option<ReferralRewardRecord>, 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<Option<ReferralRewardRecord>, 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<Vec<ReferralRewardRecord>, 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<Vec<ReferralRewardRecord>, 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<Vec<ReferralRewardRecord>, GatewayError> {
self.data
.reverse_referral_rewards_for_order(order_id, amount_usd)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -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));

View File

@@ -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!("<p>Policy</p>"),
),
(
"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": "<p>Policy</p>",
"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,