Merge branch 'pr-478'

# Conflicts:
#	apps/aether-gateway/src/data/state/mod.rs
#	apps/aether-gateway/src/handlers/admin/mod.rs
#	apps/aether-gateway/src/handlers/admin/routes.rs
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	crates/aether-data/src/repository/announcements/postgres.rs
#	frontend/src/features/auth/components/RegisterDialog.vue
This commit is contained in:
fawney19
2026-05-19 01:22:14 +08:00
56 changed files with 6283 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

@@ -1954,14 +1954,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()),
@@ -2177,7 +2201,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(
@@ -2197,11 +2221,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",
@@ -2225,7 +2252,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

@@ -142,6 +142,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,
@@ -317,6 +323,7 @@ mod core;
mod integrations;
mod models;
mod pool_scores;
mod referrals;
mod routing_profiles;
mod runtime;
#[cfg(test)]

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 routing;
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,
routing, system, users,
announcements, auth, billing, endpoint, features, model, observability, provider, referrals,
request, routing, system, users,
};
pub(crate) async fn maybe_build_local_admin_response(
@@ -44,6 +44,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,
@@ -7978,6 +8134,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,

View File

@@ -0,0 +1,62 @@
ALTER TABLE users
ADD COLUMN privacy_policy_accepted_version VARCHAR(64),
ADD COLUMN privacy_policy_accepted_at BIGINT;
ALTER TABLE announcements
ADD COLUMN requires_ack BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS user_invite_codes (
user_id VARCHAR(64) PRIMARY KEY,
invite_code VARCHAR(64) NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT user_invite_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_referrals (
id VARCHAR(64) PRIMARY KEY,
inviter_user_id VARCHAR(64) NOT NULL,
invitee_user_id VARCHAR(64) NOT NULL UNIQUE,
invite_code_snapshot VARCHAR(64) NOT NULL,
source_json TEXT,
first_paid_order_id VARCHAR(64),
first_paid_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_user_referrals_inviter (inviter_user_id, created_at),
KEY idx_user_referrals_created (created_at),
KEY idx_user_referrals_invite_code (invite_code_snapshot),
CONSTRAINT user_referrals_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_referrals_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_referrals_first_paid_order_fkey FOREIGN KEY (first_paid_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS referral_rewards (
id VARCHAR(64) PRIMARY KEY,
referral_id VARCHAR(64) NOT NULL,
inviter_user_id VARCHAR(64) NOT NULL,
invitee_user_id VARCHAR(64) NOT NULL,
reward_type VARCHAR(32) NOT NULL,
trigger_point VARCHAR(64) NOT NULL,
source_order_id VARCHAR(64),
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
amount_usd DOUBLE NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
wallet_transaction_id VARCHAR(64),
reversed_amount_usd DOUBLE NOT NULL DEFAULT 0,
pending_reversal_amount_usd DOUBLE NOT NULL DEFAULT 0,
failure_reason TEXT,
admin_operator_id VARCHAR(64),
admin_note TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_referral_rewards_inviter_status (inviter_user_id, status, created_at),
KEY idx_referral_rewards_inviter_created (inviter_user_id, created_at),
KEY idx_referral_rewards_created (created_at),
KEY idx_referral_rewards_source_order (source_order_id),
CONSTRAINT referral_rewards_referral_id_fkey FOREIGN KEY (referral_id) REFERENCES user_referrals(id) ON DELETE CASCADE,
CONSTRAINT referral_rewards_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT referral_rewards_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT referral_rewards_source_order_fkey FOREIGN KEY (source_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);

View File

@@ -0,0 +1,63 @@
ALTER TABLE public.users
ADD COLUMN privacy_policy_accepted_version character varying(64),
ADD COLUMN privacy_policy_accepted_at timestamp with time zone;
ALTER TABLE public.announcements
ADD COLUMN requires_ack boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS public.user_invite_codes (
user_id character varying(64) PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
invite_code character varying(64) NOT NULL UNIQUE,
active boolean NOT NULL DEFAULT true,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.user_referrals (
id character varying(64) PRIMARY KEY,
inviter_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
invitee_user_id character varying(64) NOT NULL UNIQUE REFERENCES public.users(id) ON DELETE CASCADE,
invite_code_snapshot character varying(64) NOT NULL,
source_json jsonb,
first_paid_order_id character varying(64) REFERENCES public.payment_orders(id) ON DELETE SET NULL,
first_paid_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter
ON public.user_referrals USING btree (inviter_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_user_referrals_created
ON public.user_referrals USING btree (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code
ON public.user_referrals USING btree (invite_code_snapshot);
CREATE TABLE IF NOT EXISTS public.referral_rewards (
id character varying(64) PRIMARY KEY,
referral_id character varying(64) NOT NULL REFERENCES public.user_referrals(id) ON DELETE CASCADE,
inviter_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
invitee_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
reward_type character varying(32) NOT NULL,
trigger_point character varying(64) NOT NULL,
source_order_id character varying(64) REFERENCES public.payment_orders(id) ON DELETE SET NULL,
idempotency_key character varying(128) NOT NULL UNIQUE,
amount_usd numeric(20,8) NOT NULL,
status character varying(32) NOT NULL DEFAULT 'pending',
wallet_transaction_id character varying(64),
reversed_amount_usd numeric(20,8) NOT NULL DEFAULT 0,
pending_reversal_amount_usd numeric(20,8) NOT NULL DEFAULT 0,
failure_reason text,
admin_operator_id character varying(64),
admin_note text,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status
ON public.referral_rewards USING btree (inviter_user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_created
ON public.referral_rewards USING btree (inviter_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_created
ON public.referral_rewards USING btree (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order
ON public.referral_rewards USING btree (source_order_id);

View File

@@ -0,0 +1,69 @@
ALTER TABLE users ADD COLUMN privacy_policy_accepted_version TEXT;
ALTER TABLE users ADD COLUMN privacy_policy_accepted_at INTEGER;
ALTER TABLE announcements ADD COLUMN requires_ack INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS user_invite_codes (
user_id TEXT PRIMARY KEY,
invite_code TEXT NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_referrals (
id TEXT PRIMARY KEY,
inviter_user_id TEXT NOT NULL,
invitee_user_id TEXT NOT NULL UNIQUE,
invite_code_snapshot TEXT NOT NULL,
source_json TEXT,
first_paid_order_id TEXT,
first_paid_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(first_paid_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter
ON user_referrals (inviter_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_user_referrals_created
ON user_referrals (created_at);
CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code
ON user_referrals (invite_code_snapshot);
CREATE TABLE IF NOT EXISTS referral_rewards (
id TEXT PRIMARY KEY,
referral_id TEXT NOT NULL,
inviter_user_id TEXT NOT NULL,
invitee_user_id TEXT NOT NULL,
reward_type TEXT NOT NULL,
trigger_point TEXT NOT NULL,
source_order_id TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
amount_usd REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
wallet_transaction_id TEXT,
reversed_amount_usd REAL NOT NULL DEFAULT 0,
pending_reversal_amount_usd REAL NOT NULL DEFAULT 0,
failure_reason TEXT,
admin_operator_id TEXT,
admin_note TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(referral_id) REFERENCES user_referrals(id) ON DELETE CASCADE,
FOREIGN KEY(inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(source_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status
ON referral_rewards (inviter_user_id, status, created_at);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_created
ON referral_rewards (inviter_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_created
ON referral_rewards (created_at);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order
ON referral_rewards (source_order_id);

View File

@@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS public.announcements (
author_id character varying(36),
is_active boolean DEFAULT true,
is_pinned boolean DEFAULT false,
requires_ack boolean DEFAULT false NOT NULL,
start_time timestamp with time zone,
end_time timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
@@ -1412,11 +1413,72 @@ CREATE TABLE IF NOT EXISTS public.users (
email_verified boolean NOT NULL,
rate_limit integer,
rate_limit_mode text DEFAULT 'system'::text NOT NULL,
privacy_policy_accepted_version character varying(64),
privacy_policy_accepted_at timestamp with time zone,
metadata json
);
--
-- Name: user_invite_codes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.user_invite_codes (
user_id character varying(64) NOT NULL,
invite_code character varying(64) NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: user_referrals; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.user_referrals (
id character varying(64) NOT NULL,
inviter_user_id character varying(64) NOT NULL,
invitee_user_id character varying(64) NOT NULL,
invite_code_snapshot character varying(64) NOT NULL,
source_json jsonb,
first_paid_order_id character varying(64),
first_paid_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: referral_rewards; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.referral_rewards (
id character varying(64) NOT NULL,
referral_id character varying(64) NOT NULL,
inviter_user_id character varying(64) NOT NULL,
invitee_user_id character varying(64) NOT NULL,
reward_type character varying(32) NOT NULL,
trigger_point character varying(64) NOT NULL,
source_order_id character varying(64),
idempotency_key character varying(128) NOT NULL,
amount_usd numeric(20,8) NOT NULL,
status character varying(32) DEFAULT 'pending'::character varying NOT NULL,
wallet_transaction_id character varying(64),
reversed_amount_usd numeric(20,8) DEFAULT 0 NOT NULL,
pending_reversal_amount_usd numeric(20,8) DEFAULT 0 NOT NULL,
failure_reason text,
admin_operator_id character varying(64),
admin_note text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: user_groups; Type: TABLE; Schema: public; Owner: -
--

View File

@@ -1227,6 +1227,96 @@ END $mig$;
--
-- Name: user_invite_codes user_invite_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_invite_codes
ADD CONSTRAINT user_invite_codes_pkey PRIMARY KEY (user_id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_invite_codes user_invite_codes_invite_code_key; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_invite_codes
ADD CONSTRAINT user_invite_codes_invite_code_key UNIQUE (invite_code);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_invitee_user_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_invitee_user_id_key UNIQUE (invitee_user_id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_idempotency_key_key; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_idempotency_key_key UNIQUE (idempotency_key);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_plan_entitlements user_plan_entitlements_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--

View File

@@ -613,6 +613,62 @@ CREATE INDEX IF NOT EXISTS idx_user_sessions_user_device ON public.user_sessions
--
-- Name: idx_user_referrals_inviter; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter ON public.user_referrals USING btree (inviter_user_id, created_at DESC);
--
-- Name: idx_user_referrals_created; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_referrals_created ON public.user_referrals USING btree (created_at DESC);
--
-- Name: idx_user_referrals_invite_code; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code ON public.user_referrals USING btree (invite_code_snapshot);
--
-- Name: idx_referral_rewards_inviter_status; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status ON public.referral_rewards USING btree (inviter_user_id, status, created_at DESC);
--
-- Name: idx_referral_rewards_inviter_created; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_created ON public.referral_rewards USING btree (inviter_user_id, created_at DESC);
--
-- Name: idx_referral_rewards_created; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_created ON public.referral_rewards USING btree (created_at DESC);
--
-- Name: idx_referral_rewards_source_order; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order ON public.referral_rewards USING btree (source_order_id);
--
-- Name: idx_video_tasks_external_id; Type: INDEX; Schema: public; Owner: -
--

View File

@@ -792,6 +792,126 @@ END $mig$;
--
-- Name: user_invite_codes user_invite_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_invite_codes
ADD CONSTRAINT user_invite_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_inviter_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_invitee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_first_paid_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_first_paid_order_id_fkey FOREIGN KEY (first_paid_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_referral_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_referral_id_fkey FOREIGN KEY (referral_id) REFERENCES public.user_referrals(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_inviter_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_invitee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_source_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_source_order_id_fkey FOREIGN KEY (source_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: video_tasks video_tasks_api_key_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260518000000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260519000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT

View File

@@ -307,6 +307,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260515000000,
20260516000000,
20260518000000,
20260519000000,
]
);
}
@@ -597,6 +598,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512090000,
20260512110000,
20260516000000,
20260519000000,
]
);
assert_eq!(
@@ -614,6 +616,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512090000,
20260512110000,
20260516000000,
20260519000000,
]
);
}
@@ -1132,6 +1135,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260515000000,
20260516000000,
20260518000000,
20260519000000,
]
);
}

View File

@@ -135,6 +135,48 @@ impl AnnouncementReadRepository for InMemoryAnnouncementReadRepository {
Ok(total)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let announcements = self
.announcements
.read()
.expect("announcement repository lock");
let reads = self
.announcement_reads
.read()
.expect("announcement reads repository lock");
let mut items = announcements
.iter()
.filter(|announcement| {
announcement.requires_ack
&& announcement.is_active
&& announcement
.start_time_unix_secs
.is_none_or(|value| value <= now_unix_secs)
&& announcement
.end_time_unix_secs
.is_none_or(|value| value >= now_unix_secs)
&& !reads.contains(&(user_id.to_string(), announcement.id.clone()))
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.is_pinned
.cmp(&left.is_pinned)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| right.created_at_unix_ms.cmp(&left.created_at_unix_ms))
.then_with(|| left.id.cmp(&right.id))
});
items.truncate(limit);
Ok(items)
}
}
#[async_trait]
@@ -153,6 +195,7 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
record.priority,
true,
record.is_pinned,
record.requires_ack,
Some(record.author_id),
None,
record.start_time_unix_secs.map(|value| value as i64),
@@ -201,6 +244,9 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
if let Some(is_pinned) = record.is_pinned {
announcement.is_pinned = is_pinned;
}
if let Some(requires_ack) = record.requires_ack {
announcement.requires_ack = requires_ack;
}
if let Some(start_time_unix_secs) = record.start_time_unix_secs {
announcement.start_time_unix_secs = Some(start_time_unix_secs);
}
@@ -261,6 +307,7 @@ mod tests {
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
@@ -291,6 +338,7 @@ mod tests {
kind: "maintenance".to_string(),
priority: 10,
is_pinned: true,
requires_ack: false,
author_id: "admin-1".to_string(),
start_time_unix_secs: None,
end_time_unix_secs: None,
@@ -308,6 +356,7 @@ mod tests {
priority: Some(99),
is_active: Some(false),
is_pinned: Some(false),
requires_ack: Some(true),
start_time_unix_secs: None,
end_time_unix_secs: None,
})
@@ -337,6 +386,7 @@ mod tests {
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
@@ -353,6 +403,7 @@ mod tests {
5,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,

View File

@@ -18,6 +18,7 @@ SELECT
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
a.start_time AS start_time_unix_secs,
@@ -144,6 +145,39 @@ WHERE a.is_active = 1
.map_sql_err()?;
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ANNOUNCEMENT_SELECT}
WHERE a.is_active = 1
AND a.requires_ack = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = ?
AND r.announcement_id = a.id
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT ?
"#
))
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.bind(user_id)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_announcement_row).collect()
}
}
#[async_trait]
@@ -159,9 +193,9 @@ impl AnnouncementWriteRepository for MysqlAnnouncementRepository {
r#"
INSERT INTO announcements (
id, title, content, `type`, priority, author_id, is_active, is_pinned,
start_time, end_time, created_at, updated_at
requires_ack, start_time, end_time, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&id)
@@ -171,6 +205,7 @@ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
.bind(record.priority)
.bind(record.author_id)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -204,6 +239,7 @@ SET title = COALESCE(?, title),
priority = COALESCE(?, priority),
is_active = COALESCE(?, is_active),
is_pinned = COALESCE(?, is_pinned),
requires_ack = COALESCE(?, requires_ack),
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
updated_at = ?
@@ -216,6 +252,7 @@ WHERE id = ?
.bind(record.priority)
.bind(record.is_active)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -303,6 +340,7 @@ fn map_announcement_row(row: &MySqlRow) -> Result<StoredAnnouncement, DataLayerE
row.try_get("priority").map_sql_err()?,
row.try_get("is_active").map_sql_err()?,
row.try_get("is_pinned").map_sql_err()?,
row.try_get("requires_ack").map_sql_err()?,
row.try_get("author_id").map_sql_err()?,
row.try_get("author_username").map_sql_err()?,
row.try_get("start_time_unix_secs").map_sql_err()?,

View File

@@ -18,6 +18,7 @@ SELECT
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
@@ -28,6 +29,38 @@ FROM announcements a
LEFT JOIN users u ON u.id = a.author_id
"#;
const LIST_REQUIRED_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL: &str = r#"
SELECT
a.id,
a.title,
a.content,
a.type,
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
EXTRACT(EPOCH FROM a.end_time)::bigint AS end_time_unix_secs,
EXTRACT(EPOCH FROM a.created_at)::bigint AS created_at_unix_ms,
EXTRACT(EPOCH FROM a.updated_at)::bigint AS updated_at_unix_secs
FROM announcements a
LEFT JOIN users u ON u.id = a.author_id
WHERE a.is_active = TRUE
AND a.requires_ack = TRUE
AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision))
AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision))
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = $1
AND r.announcement_id = a.id
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT $3
"#;
const CREATE_ANNOUNCEMENT_SQL: &str = r#"
INSERT INTO announcements (
id,
@@ -38,6 +71,7 @@ INSERT INTO announcements (
author_id,
is_active,
is_pinned,
requires_ack,
start_time,
end_time,
created_at,
@@ -54,6 +88,7 @@ VALUES (
$7,
$8,
$9,
$10,
NOW(),
NOW()
)
@@ -65,6 +100,7 @@ RETURNING
priority,
is_active,
is_pinned,
requires_ack,
author_id,
(SELECT username FROM users WHERE id = announcements.author_id) AS author_username,
EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs,
@@ -82,8 +118,9 @@ SET
priority = COALESCE($5, priority),
is_active = COALESCE($6, is_active),
is_pinned = COALESCE($7, is_pinned),
start_time = COALESCE($8, start_time),
end_time = COALESCE($9, end_time),
requires_ack = COALESCE($8, requires_ack),
start_time = COALESCE($9, start_time),
end_time = COALESCE($10, end_time),
updated_at = NOW()
WHERE id = $1
RETURNING
@@ -94,6 +131,7 @@ RETURNING
priority,
is_active,
is_pinned,
requires_ack,
author_id,
(SELECT username FROM users WHERE id = announcements.author_id) AS author_username,
EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs,
@@ -247,6 +285,22 @@ impl AnnouncementReadRepository for SqlxAnnouncementReadRepository {
.max(0) as u64;
Ok(total)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let rows = sqlx::query(LIST_REQUIRED_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL)
.bind(user_id)
.bind(now_unix_secs as f64)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
rows.iter().map(map_announcement_row).collect()
}
}
#[async_trait]
@@ -264,6 +318,7 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
.bind(record.priority)
.bind(record.author_id)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_datetime(record.start_time_unix_secs))
.bind(optional_datetime(record.end_time_unix_secs))
.fetch_one(&self.pool)
@@ -285,6 +340,7 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
.bind(record.priority)
.bind(record.is_active)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_datetime(record.start_time_unix_secs))
.bind(optional_datetime(record.end_time_unix_secs))
.fetch_optional(&self.pool)
@@ -351,6 +407,7 @@ fn map_announcement_row(row: &PgRow) -> Result<StoredAnnouncement, DataLayerErro
row.try_get("priority").map_postgres_err()?,
row.try_get("is_active").map_postgres_err()?,
row.try_get("is_pinned").map_postgres_err()?,
row.try_get("requires_ack").map_postgres_err()?,
row.try_get("author_id").map_postgres_err()?,
row.try_get("author_username").map_postgres_err()?,
row.try_get("start_time_unix_secs").map_postgres_err()?,

View File

@@ -19,6 +19,7 @@ SELECT
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
a.start_time AS start_time_unix_secs,
@@ -158,6 +159,39 @@ impl AnnouncementReadRepository for SqliteAnnouncementRepository {
.max(0) as u64;
Ok(total)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ANNOUNCEMENT_SELECT}
WHERE a.is_active = 1
AND a.requires_ack = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = ?
AND r.announcement_id = a.id
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT ?
"#
))
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.bind(user_id)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_announcement_row).collect()
}
}
#[async_trait]
@@ -173,9 +207,9 @@ impl AnnouncementWriteRepository for SqliteAnnouncementRepository {
r#"
INSERT INTO announcements (
id, title, content, type, priority, author_id, is_active, is_pinned,
start_time, end_time, created_at, updated_at
requires_ack, start_time, end_time, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&id)
@@ -185,6 +219,7 @@ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
.bind(record.priority)
.bind(record.author_id)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -218,6 +253,7 @@ SET title = COALESCE(?, title),
priority = COALESCE(?, priority),
is_active = COALESCE(?, is_active),
is_pinned = COALESCE(?, is_pinned),
requires_ack = COALESCE(?, requires_ack),
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
updated_at = ?
@@ -230,6 +266,7 @@ WHERE id = ?
.bind(record.priority)
.bind(record.is_active)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -317,6 +354,7 @@ fn map_announcement_row(row: &SqliteRow) -> Result<StoredAnnouncement, DataLayer
row.try_get("priority").map_sql_err()?,
row.try_get("is_active").map_sql_err()?,
row.try_get("is_pinned").map_sql_err()?,
row.try_get("requires_ack").map_sql_err()?,
row.try_get("author_id").map_sql_err()?,
row.try_get("author_username").map_sql_err()?,
row.try_get("start_time_unix_secs").map_sql_err()?,
@@ -355,6 +393,7 @@ mod tests {
kind: "info".to_string(),
priority: 10,
is_pinned: true,
requires_ack: false,
author_id: "user-1".to_string(),
start_time_unix_secs: Some(100),
end_time_unix_secs: Some(300),
@@ -406,6 +445,7 @@ mod tests {
priority: Some(20),
is_active: Some(false),
is_pinned: Some(false),
requires_ack: Some(true),
start_time_unix_secs: None,
end_time_unix_secs: None,
})

View File

@@ -9,6 +9,7 @@ pub struct StoredAnnouncement {
pub priority: i32,
pub is_active: bool,
pub is_pinned: bool,
pub requires_ack: bool,
pub author_id: Option<String>,
pub author_username: Option<String>,
pub start_time_unix_secs: Option<u64>,
@@ -27,6 +28,7 @@ impl StoredAnnouncement {
priority: i32,
is_active: bool,
is_pinned: bool,
requires_ack: bool,
author_id: Option<String>,
author_username: Option<String>,
start_time_unix_secs: Option<i64>,
@@ -63,6 +65,7 @@ impl StoredAnnouncement {
priority,
is_active,
is_pinned,
requires_ack,
author_id,
author_username,
start_time_unix_secs: start_time_unix_secs
@@ -117,6 +120,13 @@ pub trait AnnouncementReadRepository: Send + Sync {
user_id: &str,
now_unix_secs: u64,
) -> Result<u64, crate::DataLayerError>;
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, crate::DataLayerError>;
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -126,6 +136,7 @@ pub struct CreateAnnouncementRecord {
pub kind: String,
pub priority: i32,
pub is_pinned: bool,
pub requires_ack: bool,
pub author_id: String,
pub start_time_unix_secs: Option<u64>,
pub end_time_unix_secs: Option<u64>,
@@ -166,6 +177,7 @@ pub struct UpdateAnnouncementRecord {
pub priority: Option<i32>,
pub is_active: Option<bool>,
pub is_pinned: Option<bool>,
pub requires_ack: Option<bool>,
pub start_time_unix_secs: Option<u64>,
pub end_time_unix_secs: Option<u64>,
}

View File

@@ -8,6 +8,7 @@ export interface Announcement {
priority: number
is_pinned: boolean
is_active: boolean
requires_ack: boolean
author: {
id: string // UUID
username: string
@@ -31,6 +32,7 @@ export interface CreateAnnouncementRequest {
type?: 'info' | 'warning' | 'maintenance' | 'important'
priority?: number
is_pinned?: boolean
requires_ack?: boolean
start_time?: string
end_time?: string
}
@@ -42,6 +44,7 @@ export interface UpdateAnnouncementRequest {
priority?: number
is_active?: boolean
is_pinned?: boolean
requires_ack?: boolean
start_time?: string
end_time?: string
}
@@ -88,6 +91,11 @@ export const announcementApi = {
return response.data
},
async getRequiredUnreadAnnouncements(): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements/users/me/required-unread')
return response.data
},
// 管理员方法
// 创建公告
async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> {
@@ -106,4 +114,4 @@ export const announcementApi = {
const response = await apiClient.delete(`/api/announcements/${id}`)
return response.data
}
}
}

View File

@@ -69,6 +69,9 @@ export interface RegisterRequest {
username: string
password: string
turnstile_token?: string
invite_code?: string
privacy_policy_accepted?: boolean
privacy_policy_version?: string
}
export interface RegisterResponse {
@@ -86,6 +89,14 @@ export interface RegistrationSettingsResponse {
turnstile_enabled?: boolean
turnstile_site_key?: string | null
turnstile_required_actions?: string[]
privacy_policy?: RegistrationPrivacyPolicySettings
}
export interface RegistrationPrivacyPolicySettings {
enabled: boolean
format: 'markdown' | 'html'
content: string
version: string
}
export interface AuthSettingsResponse {

View File

@@ -0,0 +1,114 @@
import apiClient from './client'
export interface ReferralSummary {
total_invites: number
effective_invites: number
paid_reward_usd: number
pending_reward_usd: number
reversed_reward_usd: number
}
export interface ReferralDashboardResponse {
invite_code: string
invitation_link: string
summary: ReferralSummary
}
export interface ReferralRelationshipRecord {
id: string
inviter_user_id: string
inviter_username?: string | null
invitee_user_id: string
invitee_username?: string | null
invite_code_snapshot: string
first_paid_order_id?: string | null
first_paid_at_unix_secs?: number | null
source?: Record<string, unknown> | null
created_at_unix_secs: number
}
export interface ReferralRewardRecord {
id: string
referral_id: string
inviter_user_id: string
invitee_user_id: string
reward_type: string
source_order_id?: string | null
trigger_point: string
amount_usd: number
status: string
wallet_transaction_id?: string | null
idempotency_key: string
reversed_amount_usd: number
pending_reversal_amount_usd: number
admin_operator_id?: string | null
admin_note?: string | null
created_at_unix_secs: number
updated_at_unix_secs: number
}
export interface ReferralListResponse<T> {
items: T[]
total: number
limit: number
offset: number
stats: ReferralSummary
}
export interface ReferralRelationshipQuery {
inviter?: string
invitee?: string
invite_code?: string
first_paid?: boolean | null
limit?: number
offset?: number
}
export interface ReferralRewardQuery {
order_id?: string
reward_type?: string
status?: string
limit?: number
offset?: number
}
function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
) as Partial<T>
}
export const referralApi = {
async getMyReferral(): Promise<ReferralDashboardResponse> {
const response = await apiClient.get<ReferralDashboardResponse>('/api/users/me/referral')
return response.data
},
async getAdminReferrals(
params: ReferralRelationshipQuery = {}
): Promise<ReferralListResponse<ReferralRelationshipRecord>> {
const response = await apiClient.get('/api/admin/referrals', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async getAdminReferralRewards(
params: ReferralRewardQuery = {}
): Promise<ReferralListResponse<ReferralRewardRecord>> {
const response = await apiClient.get('/api/admin/referral-rewards', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async retryReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/retry`, { note })
return response.data
},
async voidReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/void`, { note })
return response.data
}
}

View File

@@ -241,6 +241,7 @@
:password-policy-level="passwordPolicyLevel"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
:privacy-policy="privacyPolicy"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -248,7 +249,7 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -259,7 +260,7 @@ import { useSiteInfo } from '@/composables/useSiteInfo'
import { normalizePasswordPolicyLevel, type PasswordPolicyLevel } from '@/utils/passwordPolicy'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
@@ -274,6 +275,7 @@ const emit = defineEmits<{
}>()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const { siteName } = useSiteInfo()
@@ -287,6 +289,12 @@ const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref<string | null>(null)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
const privacyPolicy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
// LDAP authentication settings
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
@@ -446,6 +454,12 @@ onMounted(async () => {
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
turnstileEnabled.value = !!regSettings.turnstile_enabled
turnstileSiteKey.value = regSettings.turnstile_site_key || null
privacyPolicy.value = regSettings.privacy_policy ?? {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
@@ -465,6 +479,10 @@ onMounted(async () => {
}
oauthProviders.value = providers
if (allowRegistration.value && (route.path === '/register' || typeof route.query.invite === 'string')) {
isOpen.value = false
showRegisterDialog.value = true
}
} catch {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false
@@ -473,6 +491,12 @@ onMounted(async () => {
passwordPolicyLevel.value = 'weak'
turnstileEnabled.value = false
turnstileSiteKey.value = null
privacyPolicy.value = {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false

View File

@@ -211,6 +211,46 @@
两次输入的密码不一致
</p>
</div>
<div
v-if="inviteCode"
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
>
已识别邀请码 <span class="font-mono font-semibold text-foreground">{{ inviteCode }}</span>
</div>
<div
v-if="privacyPolicyEnabled"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<label class="flex items-start gap-2 text-sm">
<Checkbox
:checked="privacyAccepted"
class="mt-0.5"
@update:checked="privacyAccepted = !!$event"
/>
<span class="leading-6">
我已阅读并同意
<button
type="button"
class="font-medium text-primary underline-offset-4 hover:underline"
@click="privacyDialogOpen = true"
>
隐私政策
</button>
<RouterLink
to="/privacy-policy"
target="_blank"
class="ml-1 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
新窗口打开
</RouterLink>
</span>
</label>
<p class="mt-2 text-xs text-muted-foreground">
当前版本{{ privacyPolicyVersion }}
</p>
</div>
</form>
<!-- 登录链接 -->
@@ -245,13 +285,37 @@
</Button>
</template>
</Dialog>
<Dialog
v-model="privacyDialogOpen"
size="2xl"
title="隐私政策"
>
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm dark:prose-invert max-h-[60vh] max-w-none overflow-y-auto"
v-html="renderedPrivacyPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
<template #footer>
<Button
type="button"
@click="privacyDialogOpen = false"
>
我知道了
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi, type RegisterRequest } from '@/api/auth'
import { RouterLink } from 'vue-router'
import { marked } from 'marked'
import { authApi, type RegisterRequest, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
import {
getPasswordPolicyHint,
getPasswordPolicyPlaceholder,
@@ -260,10 +324,13 @@ import {
} from '@/utils/passwordPolicy'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import TurnstileWidget from './TurnstileWidget.vue'
const INVITE_CODE_STORAGE_KEY = 'aether_invite_code'
interface Props {
open?: boolean
requireEmailVerification?: boolean
@@ -271,6 +338,7 @@ interface Props {
passwordPolicyLevel?: PasswordPolicyLevel
turnstileEnabled?: boolean
turnstileSiteKey?: string | null
privacyPolicy?: RegistrationPrivacyPolicySettings
}
interface Emits {
@@ -285,7 +353,13 @@ const props = withDefaults(defineProps<Props>(), {
emailConfigured: true,
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null
turnstileSiteKey: null,
privacyPolicy: () => ({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
})
const emit = defineEmits<Emits>()
@@ -422,6 +496,32 @@ const handleTurnstileError = (message: string) => {
showError(message, '人机验证失败')
}
const inviteCode = ref<string | null>(null)
const privacyAccepted = ref(false)
const privacyDialogOpen = ref(false)
const privacyPolicyEnabled = computed(() => !!props.privacyPolicy?.enabled)
const privacyPolicyVersion = computed(() => props.privacyPolicy?.version || '1')
const renderedPrivacyPolicy = computed(() => {
const policy = props.privacyPolicy
if (!policy?.content) return '<p>暂无隐私政策内容</p>'
if (policy.format === 'html') {
return sanitizeHtml(policy.content)
}
const rawHtml = marked(policy.content) as string
return sanitizeMarkdown(rawHtml)
})
function loadInviteCode(): string | null {
if (typeof window === 'undefined') return null
const fromQuery = new URLSearchParams(window.location.search).get('invite')
const normalized = (fromQuery || localStorage.getItem(INVITE_CODE_STORAGE_KEY) || '')
.trim()
.toUpperCase()
if (!normalized) return null
localStorage.setItem(INVITE_CODE_STORAGE_KEY, normalized)
return normalized
}
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
@@ -501,6 +601,10 @@ const canSubmit = computed(() => {
return false
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
return false
}
return true
})
@@ -618,7 +722,9 @@ const resetForm = () => {
isSendingCode.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
resetTurnstile()
inviteCode.value = loadInviteCode()
privacyAccepted.value = false
privacyDialogOpen.value = false
// Reset password field nonce
formNonce.value = createFormNonce()
@@ -742,6 +848,11 @@ const handleSubmit = async () => {
return
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
showError('请先阅读并同意隐私政策')
return
}
isLoading.value = true
loadingText.value = '注册中...'
@@ -758,6 +869,13 @@ const handleSubmit = async () => {
if (turnstileRequired.value && currentTurnstileAction.value === 'register') {
registerData.turnstile_token = turnstileToken.value
}
if (inviteCode.value) {
registerData.invite_code = inviteCode.value
}
if (privacyPolicyEnabled.value) {
registerData.privacy_policy_accepted = privacyAccepted.value
registerData.privacy_policy_version = privacyPolicyVersion.value
}
const response = await authApi.register(registerData)

View File

@@ -339,6 +339,43 @@
<RouterView />
<Dialog
v-model="requiredAnnouncementOpen"
persistent
size="lg"
title="必读公告"
description="请确认后继续使用"
>
<div
v-if="currentRequiredAnnouncement"
class="space-y-4"
>
<div>
<h3 class="text-lg font-semibold text-foreground">
{{ currentRequiredAnnouncement.title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
{{ formatRequiredAnnouncementDate(currentRequiredAnnouncement.created_at) }}
</p>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm dark:prose-invert max-h-[50vh] max-w-none overflow-y-auto"
v-html="renderRequiredAnnouncement(currentRequiredAnnouncement.content)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<template #footer>
<Button
type="button"
:disabled="acknowledgingRequiredAnnouncement"
@click="acknowledgeRequiredAnnouncement"
>
{{ acknowledgingRequiredAnnouncement ? '确认中...' : '确认已读' }}
</Button>
</template>
</Dialog>
<!-- 更新提示弹窗 -->
<UpdateDialog
v-if="updateInfo"
@@ -355,13 +392,16 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { marked } from 'marked'
import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import { announcementApi, type Announcement } from '@/api/announcements'
import Button from '@/components/ui/button.vue'
import { Dialog } from '@/components/ui'
import AppShell from '@/components/layout/AppShell.vue'
import SidebarNav from '@/components/layout/SidebarNav.vue'
import HeaderLogo from '@/components/HeaderLogo.vue'
@@ -393,6 +433,7 @@ import {
Wallet,
CreditCard,
Package,
Gift,
Menu,
X,
Puzzle,
@@ -406,6 +447,7 @@ import {
import GithubIcon from '@/components/icons/GithubIcon.vue'
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
const router = useRouter()
const route = useRoute()
@@ -418,6 +460,15 @@ const isAdmin = computed(() => authStore.user?.role === 'admin')
const showAuthError = ref(false)
const mobileMenuOpen = ref(false)
const requiredAnnouncements = ref<Announcement[]>([])
const acknowledgingRequiredAnnouncement = ref(false)
const requiredAnnouncementOpen = computed({
get: () => requiredAnnouncements.value.length > 0,
set: (value) => {
if (value) void loadRequiredAnnouncements()
}
})
const currentRequiredAnnouncement = computed(() => requiredAnnouncements.value[0] ?? null)
// 更新检查相关
const showUpdateDialog = ref(false)
@@ -559,10 +610,45 @@ watch(
() => [authStore.user, authStore.token] as const,
() => {
showAuthError.value = !!authStore.user && !authStore.token
if (authStore.user && authStore.token) {
void loadRequiredAnnouncements()
} else {
requiredAnnouncements.value = []
}
},
{ immediate: true }
)
async function loadRequiredAnnouncements() {
if (!authStore.user || !authStore.token) return
try {
const response = await announcementApi.getRequiredUnreadAnnouncements()
requiredAnnouncements.value = response.items.filter(item => item.requires_ack && !item.is_read)
} catch {
requiredAnnouncements.value = []
}
}
function renderRequiredAnnouncement(content: string): string {
return sanitizeMarkdown(marked(content || '') as string)
}
function formatRequiredAnnouncementDate(value: string): string {
return new Date(value).toLocaleString('zh-CN')
}
async function acknowledgeRequiredAnnouncement() {
const announcement = currentRequiredAnnouncement.value
if (!announcement) return
acknowledgingRequiredAnnouncement.value = true
try {
await announcementApi.markAsRead(announcement.id)
requiredAnnouncements.value = requiredAnnouncements.value.slice(1)
} finally {
acknowledgingRequiredAnnouncement.value = false
}
}
onMounted(() => {
window.addEventListener('storage', handleStorageChange)
document.addEventListener('visibilitychange', handleVisibilityChange)
@@ -573,6 +659,7 @@ onMounted(() => {
moduleStore.fetchModules()
}
void loadVersionStatus()
void loadRequiredAnnouncements()
// 延迟检查更新,避免影响页面加载
setTimeout(() => {
@@ -640,6 +727,7 @@ const navigation = computed(() => {
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
{ name: '我的邀请', href: '/dashboard/referral', icon: Gift },
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
]
}
@@ -702,6 +790,7 @@ const navigation = computed(() => {
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '支付配置', href: '/admin/payment-gateways', icon: CreditCard },
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
{ name: '邀请返利', href: '/admin/referrals', icon: Gift },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]

View File

@@ -18,6 +18,18 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
meta: { requiresAuth: false }
},
{
path: '/register',
name: 'RegisterEntry',
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
meta: { requiresAuth: false }
},
{
path: '/privacy-policy',
name: 'PrivacyPolicy',
component: () => importWithRetry(() => import('@/views/public/PrivacyPolicy.vue')),
meta: { requiresAuth: false }
},
{
path: '/guide',
@@ -132,6 +144,11 @@ const routes: RouteRecordRaw[] = [
name: 'BillingPlans',
component: () => importWithRetry(() => import('@/views/user/BillingPlans.vue'))
},
{
path: 'referral',
name: 'ReferralCenter',
component: () => importWithRetry(() => import('@/views/user/ReferralCenter.vue'))
},
{
path: 'models',
name: 'ModelCatalog',
@@ -179,6 +196,11 @@ const routes: RouteRecordRaw[] = [
name: 'BillingPlansManagement',
component: () => importWithRetry(() => import('@/views/admin/BillingPlansManagement.vue'))
},
{
path: 'referrals',
name: 'ReferralManagement',
component: () => importWithRetry(() => import('@/views/admin/ReferralManagement.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',

View File

@@ -0,0 +1,439 @@
<template>
<div class="space-y-6 pb-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-foreground">
邀请返利
</h1>
<p class="mt-1 text-sm text-muted-foreground">
查看邀请关系返利记录和失败返利处理状态
</p>
</div>
<Button
variant="outline"
:disabled="loading"
@click="loadAll"
>
<RefreshCw
class="mr-2 h-4 w-4"
:class="{ 'animate-spin': loading }"
/>
刷新
</Button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
<Card
v-for="item in statCards"
:key="item.label"
class="p-4"
>
<p class="text-xs text-muted-foreground">
{{ item.label }}
</p>
<p class="mt-2 text-xl font-semibold">
{{ item.value }}
</p>
</Card>
</div>
<Card class="overflow-hidden">
<div class="border-b border-border px-5 py-4">
<h2 class="text-base font-semibold">
邀请关系
</h2>
</div>
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
<Input
v-model="relationshipFilters.inviter"
placeholder="邀请人"
/>
<Input
v-model="relationshipFilters.invitee"
placeholder="被邀请人"
/>
<Input
v-model="relationshipFilters.invite_code"
placeholder="邀请码"
/>
<Select v-model="firstPaidFilter">
<SelectTrigger>
<SelectValue placeholder="首付状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="true">
已首付
</SelectItem>
<SelectItem value="false">
未首付
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
@click="loadRelationships"
>
查询
</Button>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>邀请人</TableHead>
<TableHead>被邀请人</TableHead>
<TableHead>邀请码</TableHead>
<TableHead>绑定时间</TableHead>
<TableHead>首付状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="item in relationships"
:key="item.id"
>
<TableCell>{{ item.inviter_username || item.inviter_user_id }}</TableCell>
<TableCell>{{ item.invitee_username || item.invitee_user_id }}</TableCell>
<TableCell class="font-mono text-xs">
{{ item.invite_code_snapshot }}
</TableCell>
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
<TableCell>
<Badge :variant="item.first_paid_order_id ? 'success' : 'secondary'">
{{ item.first_paid_order_id ? '已首付' : '未首付' }}
</Badge>
</TableCell>
</TableRow>
<TableRow v-if="relationships.length === 0">
<TableCell
colspan="5"
class="py-8 text-center text-sm text-muted-foreground"
>
暂无邀请关系
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-border px-5 py-4">
<h2 class="text-base font-semibold">
返利记录
</h2>
</div>
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
<Input
v-model="rewardFilters.order_id"
placeholder="订单号"
/>
<Select v-model="rewardFilters.reward_type">
<SelectTrigger>
<SelectValue placeholder="返利类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部类型
</SelectItem>
<SelectItem value="percent">
比例返利
</SelectItem>
<SelectItem value="headcount">
人头返利
</SelectItem>
</SelectContent>
</Select>
<Select v-model="rewardFilters.status">
<SelectTrigger>
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="pending">
待发
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="applied">
已发
</SelectItem>
<SelectItem value="voided">
已作废
</SelectItem>
<SelectItem value="reversed">
已冲回
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
class="md:col-start-5"
@click="loadRewards"
>
查询
</Button>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>类型</TableHead>
<TableHead>来源订单</TableHead>
<TableHead>金额</TableHead>
<TableHead>状态</TableHead>
<TableHead>冲回</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="item in rewards"
:key="item.id"
>
<TableCell>{{ getRewardTypeLabel(item.reward_type) }}</TableCell>
<TableCell class="font-mono text-xs">
{{ item.source_order_id || '-' }}
</TableCell>
<TableCell>{{ formatUsd(item.amount_usd) }}</TableCell>
<TableCell>
<Badge :variant="getRewardStatusVariant(item.status)">
{{ getRewardStatusLabel(item.status) }}
</Badge>
</TableCell>
<TableCell>
{{ formatUsd(item.reversed_amount_usd) }}
<span
v-if="item.pending_reversal_amount_usd > 0"
class="text-xs text-amber-600 dark:text-amber-400"
>
/ 待冲回 {{ formatUsd(item.pending_reversal_amount_usd) }}
</span>
</TableCell>
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button
v-if="item.status === 'failed'"
variant="outline"
size="sm"
:disabled="mutatingRewardId === item.id"
@click="retryReward(item)"
>
补发
</Button>
<Button
v-if="item.status === 'failed' || item.status === 'pending'"
variant="ghost"
size="sm"
:disabled="mutatingRewardId === item.id"
@click="voidReward(item)"
>
作废
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="rewards.length === 0">
<TableCell
colspan="7"
class="py-8 text-center text-sm text-muted-foreground"
>
暂无返利记录
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import {
referralApi,
type ReferralRelationshipRecord,
type ReferralRewardRecord,
type ReferralSummary
} from '@/api/referrals'
import {
Badge,
Button,
Card,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
const relationships = ref<ReferralRelationshipRecord[]>([])
const rewards = ref<ReferralRewardRecord[]>([])
const stats = ref<ReferralSummary>({
total_invites: 0,
effective_invites: 0,
paid_reward_usd: 0,
pending_reward_usd: 0,
reversed_reward_usd: 0
})
const loading = ref(false)
const mutatingRewardId = ref<string | null>(null)
const relationshipFilters = ref({
inviter: '',
invitee: '',
invite_code: ''
})
const firstPaidFilter = ref('all')
const rewardFilters = ref({
order_id: '',
reward_type: 'all',
status: 'all'
})
const { success, error: showError } = useToast()
const statCards = computed(() => [
{ label: '总邀请', value: stats.value.total_invites },
{ label: '有效邀请', value: stats.value.effective_invites },
{ label: '已发返利', value: formatUsd(stats.value.paid_reward_usd) },
{ label: '待发返利', value: formatUsd(stats.value.pending_reward_usd) },
{ label: '已冲回返利', value: formatUsd(stats.value.reversed_reward_usd) },
])
function formatUsd(value: number): string {
return `$${Number(value || 0).toFixed(2)}`
}
function formatUnix(value?: number | null): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString('zh-CN')
}
function getRewardTypeLabel(value: string): string {
if (value === 'percent') return '比例返利'
if (value === 'headcount') return '人头返利'
return value
}
function getRewardStatusLabel(value: string): string {
switch (value) {
case 'applied':
return '已发'
case 'pending':
return '待发'
case 'failed':
return '失败'
case 'voided':
return '已作废'
case 'reversed':
return '已冲回'
default:
return value
}
}
function getRewardStatusVariant(value: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' {
switch (value) {
case 'applied':
return 'success'
case 'failed':
return 'destructive'
case 'pending':
return 'warning'
case 'voided':
return 'secondary'
default:
return 'outline'
}
}
async function loadRelationships() {
const firstPaid =
firstPaidFilter.value === 'true' ? true : firstPaidFilter.value === 'false' ? false : null
const response = await referralApi.getAdminReferrals({
...relationshipFilters.value,
first_paid: firstPaid,
limit: 100,
offset: 0
})
relationships.value = response.items
stats.value = response.stats
}
async function loadRewards() {
const response = await referralApi.getAdminReferralRewards({
order_id: rewardFilters.value.order_id,
reward_type: rewardFilters.value.reward_type === 'all' ? undefined : rewardFilters.value.reward_type,
status: rewardFilters.value.status === 'all' ? undefined : rewardFilters.value.status,
limit: 100,
offset: 0
})
rewards.value = response.items
stats.value = response.stats
}
async function loadAll() {
loading.value = true
try {
await Promise.all([loadRelationships(), loadRewards()])
} catch {
showError('加载邀请返利数据失败')
} finally {
loading.value = false
}
}
async function retryReward(item: ReferralRewardRecord) {
mutatingRewardId.value = item.id
try {
const response = await referralApi.retryReferralReward(item.id, '管理员后台补发')
replaceReward(response.reward)
success('返利已补发')
} catch {
showError('补发失败')
} finally {
mutatingRewardId.value = null
}
}
async function voidReward(item: ReferralRewardRecord) {
mutatingRewardId.value = item.id
try {
const response = await referralApi.voidReferralReward(item.id, '管理员后台作废')
replaceReward(response.reward)
success('返利已作废')
} catch {
showError('作废失败')
} finally {
mutatingRewardId.value = null
}
}
function replaceReward(updated: ReferralRewardRecord) {
rewards.value = rewards.value.map(item => item.id === updated.id ? updated : item)
}
onMounted(() => {
void loadAll()
})
</script>

View File

@@ -58,6 +58,15 @@
:turnstile-secret-key="systemConfig.turnstile_secret_key"
:turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set"
:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr"
:referral-enabled="systemConfig.referral_enabled"
:referral-reward-mode="systemConfig.referral_reward_mode"
:referral-recharge-percent="systemConfig.referral_recharge_percent"
:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd"
:referral-headcount-trigger="systemConfig.referral_headcount_trigger"
:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled"
:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format"
:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content"
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
@@ -73,6 +82,15 @@
@update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event"
@update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event"
@clear-turnstile-secret="clearTurnstileSecret"
@update:referral-enabled="systemConfig.referral_enabled = $event"
@update:referral-reward-mode="systemConfig.referral_reward_mode = $event"
@update:referral-recharge-percent="systemConfig.referral_recharge_percent = $event"
@update:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd = $event"
@update:referral-headcount-trigger="systemConfig.referral_headcount_trigger = $event"
@update:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled = $event"
@update:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format = $event"
@update:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content = $event"
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"

View File

@@ -262,6 +262,203 @@
</p>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="referral-enabled"
:checked="referralEnabled"
@update:checked="$emit('update:referralEnabled', $event)"
/>
<div>
<Label
for="referral-enabled"
class="cursor-pointer"
>
邀请返利
</Label>
<p class="text-xs text-muted-foreground">
开启后可按充值比例人头或两者同时发放赠款返利
</p>
</div>
</div>
</div>
<div>
<Label
for="referral-reward-mode"
class="block text-sm font-medium mb-2"
>
返利方式
</Label>
<Select
:model-value="referralRewardMode"
@update:model-value="$emit('update:referralRewardMode', $event)"
>
<SelectTrigger id="referral-reward-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percent">
按充值比例
</SelectItem>
<SelectItem value="headcount">
按邀请人头
</SelectItem>
<SelectItem value="both">
两者同时启用
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label
for="referral-recharge-percent"
class="block text-sm font-medium"
>
充值返利比例 (%)
</Label>
<Input
id="referral-recharge-percent"
:model-value="referralRechargePercent"
type="number"
min="0"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralRechargePercent', Number($event))"
/>
</div>
<div>
<Label
for="referral-headcount-amount"
class="block text-sm font-medium"
>
人头返利金额 (美元)
</Label>
<Input
id="referral-headcount-amount"
:model-value="referralHeadcountAmountUsd"
type="number"
min="0"
step="0.01"
class="mt-1"
@update:model-value="$emit('update:referralHeadcountAmountUsd', Number($event))"
/>
</div>
<div>
<Label
for="referral-headcount-trigger"
class="block text-sm font-medium mb-2"
>
人头返利触发时机
</Label>
<Select
:model-value="referralHeadcountTrigger"
@update:model-value="$emit('update:referralHeadcountTrigger', $event)"
>
<SelectTrigger id="referral-headcount-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="registration">
注册成功
</SelectItem>
<SelectItem value="email_verified">
邮箱验证完成
</SelectItem>
<SelectItem value="first_paid_order">
首笔真实支付完成
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="privacy-policy-enabled"
:checked="registrationPrivacyPolicyEnabled"
@update:checked="$emit('update:registrationPrivacyPolicyEnabled', $event)"
/>
<div>
<Label
for="privacy-policy-enabled"
class="cursor-pointer"
>
注册隐私政策确认
</Label>
<p class="text-xs text-muted-foreground">
开启后注册时必须确认当前版本
</p>
</div>
</div>
</div>
<div>
<Label
for="privacy-policy-version"
class="block text-sm font-medium"
>
隐私政策版本
</Label>
<Input
id="privacy-policy-version"
:model-value="registrationPrivacyPolicyVersion"
type="text"
placeholder="2026-05-16"
class="mt-1"
@update:model-value="$emit('update:registrationPrivacyPolicyVersion', String($event || '').trim())"
/>
</div>
<div>
<Label
for="privacy-policy-format"
class="block text-sm font-medium mb-2"
>
隐私政策格式
</Label>
<Select
:model-value="registrationPrivacyPolicyFormat"
@update:model-value="$emit('update:registrationPrivacyPolicyFormat', $event)"
>
<SelectTrigger id="privacy-policy-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="markdown">
Markdown
</SelectItem>
<SelectItem value="html">
HTML
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="md:col-span-2">
<Label
for="privacy-policy-content"
class="block text-sm font-medium"
>
隐私政策内容
</Label>
<Textarea
id="privacy-policy-content"
:model-value="registrationPrivacyPolicyContent"
rows="8"
class="mt-1"
placeholder="填写 Markdown 或 HTML 内容"
@update:model-value="$emit('update:registrationPrivacyPolicyContent', $event)"
/>
</div>
</div>
</div>
</CardSection>
</template>
@@ -270,6 +467,7 @@
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Textarea from '@/components/ui/textarea.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
@@ -288,6 +486,15 @@ defineProps<{
turnstileSecretKey: string
turnstileSecretConfigured: boolean
turnstileAllowedHostnamesStr: string
referralEnabled: boolean
referralRewardMode: string
referralRechargePercent: number
referralHeadcountAmountUsd: number
referralHeadcountTrigger: string
registrationPrivacyPolicyEnabled: boolean
registrationPrivacyPolicyFormat: string
registrationPrivacyPolicyContent: string
registrationPrivacyPolicyVersion: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
@@ -306,6 +513,15 @@ defineEmits<{
'update:turnstileSecretKey': [value: string]
'update:turnstileAllowedHostnamesStr': [value: string]
clearTurnstileSecret: []
'update:referralEnabled': [value: boolean]
'update:referralRewardMode': [value: string]
'update:referralRechargePercent': [value: number]
'update:referralHeadcountAmountUsd': [value: number]
'update:referralHeadcountTrigger': [value: string]
'update:registrationPrivacyPolicyEnabled': [value: boolean]
'update:registrationPrivacyPolicyFormat': [value: string]
'update:registrationPrivacyPolicyContent': [value: string]
'update:registrationPrivacyPolicyVersion': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]

View File

@@ -20,6 +20,15 @@ export interface SystemConfig {
turnstile_secret_key: string
turnstile_secret_key_is_set: boolean
turnstile_allowed_hostnames: string[]
referral_enabled: boolean
referral_reward_mode: string
referral_recharge_percent: number
referral_headcount_amount_usd: number
referral_headcount_trigger: string
registration_privacy_policy_enabled: boolean
registration_privacy_policy_format: string
registration_privacy_policy_content: string
registration_privacy_policy_version: string
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
@@ -65,6 +74,15 @@ const CONFIG_KEYS = [
'turnstile_site_key',
'turnstile_secret_key',
'turnstile_allowed_hostnames',
'referral_enabled',
'referral_reward_mode',
'referral_recharge_percent',
'referral_headcount_amount_usd',
'referral_headcount_trigger',
'registration_privacy_policy_enabled',
'registration_privacy_policy_format',
'registration_privacy_policy_content',
'registration_privacy_policy_version',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
@@ -112,6 +130,15 @@ function createDefaultConfig(): SystemConfig {
turnstile_secret_key: '',
turnstile_secret_key_is_set: false,
turnstile_allowed_hostnames: [],
referral_enabled: false,
referral_reward_mode: 'percent',
referral_recharge_percent: 5,
referral_headcount_amount_usd: 0,
referral_headcount_trigger: 'registration',
registration_privacy_policy_enabled: false,
registration_privacy_policy_format: 'markdown',
registration_privacy_policy_content: '',
registration_privacy_policy_version: '1',
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
@@ -184,6 +211,19 @@ export function useSystemConfig() {
systemConfig.value.turnstile_secret_key.trim() !== '' ||
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
systemConfig.value.referral_enabled !== originalConfig.value.referral_enabled ||
systemConfig.value.referral_reward_mode !== originalConfig.value.referral_reward_mode ||
systemConfig.value.referral_recharge_percent !== originalConfig.value.referral_recharge_percent ||
systemConfig.value.referral_headcount_amount_usd !== originalConfig.value.referral_headcount_amount_usd ||
systemConfig.value.referral_headcount_trigger !== originalConfig.value.referral_headcount_trigger ||
systemConfig.value.registration_privacy_policy_enabled !==
originalConfig.value.registration_privacy_policy_enabled ||
systemConfig.value.registration_privacy_policy_format !==
originalConfig.value.registration_privacy_policy_format ||
systemConfig.value.registration_privacy_policy_content !==
originalConfig.value.registration_privacy_policy_content ||
systemConfig.value.registration_privacy_policy_version !==
originalConfig.value.registration_privacy_policy_version ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
@@ -386,6 +426,51 @@ export function useSystemConfig() {
value: systemConfig.value.turnstile_allowed_hostnames,
description: 'Cloudflare Turnstile 允许的 hostname 列表',
},
{
key: 'referral_enabled',
value: systemConfig.value.referral_enabled,
description: '邀请返利开关',
},
{
key: 'referral_reward_mode',
value: systemConfig.value.referral_reward_mode,
description: '邀请返利方式',
},
{
key: 'referral_recharge_percent',
value: systemConfig.value.referral_recharge_percent,
description: '邀请充值比例返利百分比',
},
{
key: 'referral_headcount_amount_usd',
value: systemConfig.value.referral_headcount_amount_usd,
description: '邀请人头返利金额(美元)',
},
{
key: 'referral_headcount_trigger',
value: systemConfig.value.referral_headcount_trigger,
description: '邀请人头返利触发时机',
},
{
key: 'registration_privacy_policy_enabled',
value: systemConfig.value.registration_privacy_policy_enabled,
description: '注册隐私政策确认开关',
},
{
key: 'registration_privacy_policy_format',
value: systemConfig.value.registration_privacy_policy_format,
description: '注册隐私政策内容格式',
},
{
key: 'registration_privacy_policy_content',
value: systemConfig.value.registration_privacy_policy_content,
description: '注册隐私政策内容',
},
{
key: 'registration_privacy_policy_version',
value: systemConfig.value.registration_privacy_policy_version,
description: '注册隐私政策版本',
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -426,6 +511,21 @@ export function useSystemConfig() {
originalConfig.value.turnstile_allowed_hostnames = [
...systemConfig.value.turnstile_allowed_hostnames,
]
originalConfig.value.referral_enabled = systemConfig.value.referral_enabled
originalConfig.value.referral_reward_mode = systemConfig.value.referral_reward_mode
originalConfig.value.referral_recharge_percent = systemConfig.value.referral_recharge_percent
originalConfig.value.referral_headcount_amount_usd =
systemConfig.value.referral_headcount_amount_usd
originalConfig.value.referral_headcount_trigger =
systemConfig.value.referral_headcount_trigger
originalConfig.value.registration_privacy_policy_enabled =
systemConfig.value.registration_privacy_policy_enabled
originalConfig.value.registration_privacy_policy_format =
systemConfig.value.registration_privacy_policy_format
originalConfig.value.registration_privacy_policy_content =
systemConfig.value.registration_privacy_policy_content
originalConfig.value.registration_privacy_policy_version =
systemConfig.value.registration_privacy_policy_version
if (turnstileSecret) {
systemConfig.value.turnstile_secret_key = ''
systemConfig.value.turnstile_secret_key_is_set = true

View File

@@ -0,0 +1,102 @@
<template>
<main class="min-h-screen bg-[#faf9f5] text-[#3d3929] dark:bg-[#191714] dark:text-[#e3e0d3]">
<header class="border-b border-[#3d3929]/10 dark:border-white/10">
<div class="mx-auto flex max-w-4xl items-center justify-between px-5 py-4">
<RouterLink
to="/"
class="flex items-center gap-3"
>
<HeaderLogo
size="h-9 w-9"
class-name="text-[#191919] dark:text-white"
/>
<div>
<div class="text-sm font-semibold">
{{ siteName }}
</div>
<div class="text-xs text-muted-foreground">
隐私政策
</div>
</div>
</RouterLink>
<RouterLink
to="/"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
返回首页
</RouterLink>
</div>
</header>
<section class="mx-auto max-w-4xl px-5 py-8">
<div class="mb-6">
<h1 class="text-2xl font-semibold">
隐私政策
</h1>
<p class="mt-2 text-sm text-muted-foreground">
当前版本{{ policy.version || '1' }}
</p>
</div>
<div
v-if="loading"
class="rounded-lg border border-border bg-background/70 p-6 text-sm text-muted-foreground"
>
正在加载...
</div>
<div
v-else-if="loadError"
class="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-sm text-destructive"
>
{{ loadError }}
</div>
<!-- eslint-disable vue/no-v-html -->
<article
v-else
class="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-background/70 p-6"
v-html="renderedPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { marked } from 'marked'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import HeaderLogo from '@/components/HeaderLogo.vue'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
const { siteName } = useSiteInfo()
const loading = ref(true)
const loadError = ref('')
const policy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: '1'
})
const renderedPolicy = computed(() => {
if (!policy.value.content) return '<p>暂无隐私政策内容。</p>'
if (policy.value.format === 'html') {
return sanitizeHtml(policy.value.content)
}
return sanitizeMarkdown(marked(policy.value.content) as string)
})
onMounted(async () => {
loading.value = true
loadError.value = ''
try {
const settings = await authApi.getRegistrationSettings()
policy.value = settings.privacy_policy ?? policy.value
} catch {
loadError.value = '隐私政策加载失败,请稍后重试。'
} finally {
loading.value = false
}
})
</script>

View File

@@ -132,6 +132,13 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-foreground">{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
必读
</Badge>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
@@ -240,6 +247,13 @@
:class="getIconColor(announcement.type)"
/>
<span class="font-medium text-sm">{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
class="text-[10px] shrink-0"
>
必读
</Badge>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
@@ -433,6 +447,18 @@
class="cursor-pointer text-sm"
>置顶公告</Label>
</div>
<div class="flex items-center gap-2">
<input
id="requires-ack"
v-model="formData.requires_ack"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
>
<Label
for="requires-ack"
class="cursor-pointer text-sm"
>必读确认</Label>
</div>
<div
v-if="editingAnnouncement"
class="flex items-center gap-2"
@@ -611,7 +637,8 @@ const formData = ref({
type: 'info' as 'info' | 'warning' | 'maintenance' | 'important',
priority: 0,
is_pinned: false,
is_active: true
is_active: true,
requires_ack: false
})
onMounted(() => {
@@ -663,7 +690,8 @@ function openCreateDialog() {
type: 'info',
priority: 0,
is_pinned: false,
is_active: true
is_active: true,
requires_ack: false
}
dialogOpen.value = true
}
@@ -676,7 +704,8 @@ function openEditDialog(announcement: Announcement) {
type: announcement.type,
priority: announcement.priority,
is_pinned: announcement.is_pinned,
is_active: announcement.is_active
is_active: announcement.is_active,
requires_ack: !!announcement.requires_ack
}
dialogOpen.value = true
}

View File

@@ -0,0 +1,155 @@
<template>
<div class="space-y-6 pb-8">
<div>
<h1 class="text-2xl font-semibold text-foreground">
我的邀请
</h1>
<p class="mt-1 text-sm text-muted-foreground">
分享邀请码后符合规则的返利会进入赠款余额
</p>
</div>
<div
v-if="loading"
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
>
正在加载...
</div>
<template v-else-if="dashboard">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card class="p-5">
<p class="text-xs text-muted-foreground">
总邀请
</p>
<p class="mt-2 text-2xl font-semibold">
{{ dashboard.summary.total_invites }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
有效邀请
</p>
<p class="mt-2 text-2xl font-semibold">
{{ dashboard.summary.effective_invites }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
已发返利
</p>
<p class="mt-2 text-2xl font-semibold">
{{ formatUsd(dashboard.summary.paid_reward_usd) }}
</p>
</Card>
</div>
<Card class="p-5">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[240px_1fr]">
<div>
<Label class="text-xs text-muted-foreground">
邀请码
</Label>
<div class="mt-2 flex items-center gap-2">
<code class="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-sm">
{{ dashboard.invite_code }}
</code>
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(dashboard.invite_code)"
>
<Copy class="mr-2 h-4 w-4" />
复制
</Button>
</div>
</div>
<div>
<Label class="text-xs text-muted-foreground">
邀请链接
</Label>
<div class="mt-2 flex min-w-0 items-center gap-2">
<Input
:model-value="dashboard.invitation_link"
readonly
class="min-w-0"
/>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
@click="copyToClipboard(dashboard.invitation_link)"
>
<Copy class="mr-2 h-4 w-4" />
复制
</Button>
</div>
</div>
</div>
</Card>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card class="p-5">
<p class="text-xs text-muted-foreground">
待发返利
</p>
<p class="mt-2 text-xl font-semibold">
{{ formatUsd(dashboard.summary.pending_reward_usd) }}
</p>
</Card>
<Card class="p-5">
<p class="text-xs text-muted-foreground">
已冲回返利
</p>
<p class="mt-2 text-xl font-semibold">
{{ formatUsd(dashboard.summary.reversed_reward_usd) }}
</p>
</Card>
</div>
</template>
<div
v-else
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
>
邀请数据暂不可用
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Copy } from 'lucide-vue-next'
import { referralApi, type ReferralDashboardResponse } from '@/api/referrals'
import { Button, Card, Input, Label } from '@/components/ui'
import { useClipboard } from '@/composables/useClipboard'
import { useToast } from '@/composables/useToast'
const dashboard = ref<ReferralDashboardResponse | null>(null)
const loading = ref(false)
const { copyToClipboard } = useClipboard()
const { error: showError } = useToast()
function formatUsd(value: number): string {
return `$${Number(value || 0).toFixed(2)}`
}
async function loadReferralDashboard() {
loading.value = true
try {
dashboard.value = await referralApi.getMyReferral()
} catch {
dashboard.value = null
showError('加载邀请数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadReferralDashboard()
})
</script>