Compare commits
3 Commits
3d54c7d529
...
d697146941
| Author | SHA1 | Date | |
|---|---|---|---|
| d697146941 | |||
| d1f3f4e93d | |||
| df97633300 |
Generated
+3
@@ -399,6 +399,8 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"figment",
|
"figment",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -411,6 +413,7 @@ dependencies = [
|
|||||||
"rstest",
|
"rstest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ once_cell = "1"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
|
||||||
|
|||||||
+19
@@ -29,6 +29,25 @@ proxy_log_retention_days = 30
|
|||||||
# Supports http://, https:// and socks5:// proxy URLs.
|
# Supports http://, https:// and socks5:// proxy URLs.
|
||||||
# template_proxy_url = "http://127.0.0.1:7890"
|
# template_proxy_url = "http://127.0.0.1:7890"
|
||||||
|
|
||||||
|
[payment]
|
||||||
|
enabled = false
|
||||||
|
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
||||||
|
# Example: "https://tybossai.com"
|
||||||
|
public_base_url = ""
|
||||||
|
product_name_prefix = "BossAI 账户充值"
|
||||||
|
currency = "usd"
|
||||||
|
min_amount_usd = 1.0
|
||||||
|
max_amount_usd = 100000.0
|
||||||
|
order_timeout_minutes = 30
|
||||||
|
max_pending_orders = 3
|
||||||
|
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
||||||
|
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
||||||
|
# Prefer environment variables for secrets:
|
||||||
|
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
stripe_secret_key = ""
|
||||||
|
stripe_webhook_secret = ""
|
||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
public_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
recharge_record_id INTEGER,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
payment_method TEXT NOT NULL DEFAULT 'stripe',
|
||||||
|
provider_session_id TEXT,
|
||||||
|
provider_payment_id TEXT,
|
||||||
|
amount_usd REAL NOT NULL,
|
||||||
|
amount_minor INTEGER NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'usd',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
checkout_url TEXT,
|
||||||
|
success_url TEXT,
|
||||||
|
cancel_url TEXT,
|
||||||
|
note TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
paid_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
|
||||||
|
ON payment_orders (public_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
|
||||||
|
ON payment_orders (provider, provider_session_id)
|
||||||
|
WHERE provider_session_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
|
||||||
|
ON payment_orders (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
|
||||||
|
ON payment_orders (status);
|
||||||
@@ -13,6 +13,8 @@ pub struct AppConfig {
|
|||||||
pub proxy: ProxyConfig,
|
pub proxy: ProxyConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comfyui: ComfyUIConfig,
|
pub comfyui: ComfyUIConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment: PaymentConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -75,6 +77,23 @@ pub struct ComfyUIConfig {
|
|||||||
pub agent_model: String,
|
pub agent_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct PaymentConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub public_base_url: Option<String>,
|
||||||
|
pub product_name_prefix: String,
|
||||||
|
pub currency: String,
|
||||||
|
pub min_amount_usd: f64,
|
||||||
|
pub max_amount_usd: Option<f64>,
|
||||||
|
pub daily_limit_usd: Option<f64>,
|
||||||
|
pub order_timeout_minutes: i64,
|
||||||
|
pub max_pending_orders: i64,
|
||||||
|
pub stripe_secret_key: Option<String>,
|
||||||
|
pub stripe_webhook_secret: Option<String>,
|
||||||
|
pub success_path: String,
|
||||||
|
pub cancel_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone, Default)]
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ComfyUIBackend {
|
pub enum ComfyUIBackend {
|
||||||
@@ -116,6 +135,26 @@ impl Default for ComfyUIConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for PaymentConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
public_base_url: None,
|
||||||
|
product_name_prefix: "BossAI 账户充值".to_string(),
|
||||||
|
currency: "usd".to_string(),
|
||||||
|
min_amount_usd: 1.0,
|
||||||
|
max_amount_usd: Some(100_000.0),
|
||||||
|
daily_limit_usd: None,
|
||||||
|
order_timeout_minutes: 30,
|
||||||
|
max_pending_orders: 3,
|
||||||
|
stripe_secret_key: None,
|
||||||
|
stripe_webhook_secret: None,
|
||||||
|
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
|
||||||
|
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ComfyUIConfig {
|
impl ComfyUIConfig {
|
||||||
pub fn uses_native_backend(&self) -> bool {
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
|
|||||||
+84
-30
@@ -708,22 +708,25 @@ struct LoginAuditEvent<'a> {
|
|||||||
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
||||||
let event_type = "user_login";
|
let event_type = "user_login";
|
||||||
let level = if audit.success { "info" } else { "warning" };
|
let level = if audit.success { "info" } else { "warning" };
|
||||||
|
let account_label = display_user_for_audit(audit.user, audit.identifier);
|
||||||
let title = if audit.success {
|
let title = if audit.success {
|
||||||
format!("用户登录成功:{}", display_user_for_audit(audit.user, audit.identifier))
|
format!("用户登录成功:{account_label}")
|
||||||
} else {
|
} else {
|
||||||
format!("用户登录失败:{}", display_user_for_audit(audit.user, audit.identifier))
|
format!("用户登录失败:{account_label}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut message_parts = vec![
|
let mut message_parts = Vec::new();
|
||||||
format!("方式={}", audit.method.label()),
|
|
||||||
format!("IP={}", audit.client_ip),
|
|
||||||
];
|
|
||||||
if let Some(user) = audit.user {
|
if let Some(user) = audit.user {
|
||||||
|
message_parts.push(format!("账号={}", user_account_label_for_audit(user)));
|
||||||
|
if !user.email.trim().is_empty() {
|
||||||
|
message_parts.push(format!("邮箱={}", user.email.trim()));
|
||||||
|
}
|
||||||
message_parts.push(format!("用户ID={}", user.id));
|
message_parts.push(format!("用户ID={}", user.id));
|
||||||
message_parts.push(format!("邮箱={}", user.email));
|
|
||||||
} else if let Some(identifier) = audit.identifier {
|
} else if let Some(identifier) = audit.identifier {
|
||||||
message_parts.push(format!("标识={}", identifier));
|
message_parts.push(format!("标识={}", identifier));
|
||||||
}
|
}
|
||||||
|
message_parts.push(format!("方式={}", audit.method.label()));
|
||||||
|
message_parts.push(format!("IP={}", audit.client_ip));
|
||||||
if let Some(api_key) = audit.api_key {
|
if let Some(api_key) = audit.api_key {
|
||||||
message_parts.push(format!("密钥={}", api_key.name));
|
message_parts.push(format!("密钥={}", api_key.name));
|
||||||
message_parts.push(format!("密钥ID={}", api_key.id));
|
message_parts.push(format!("密钥ID={}", api_key.id));
|
||||||
@@ -768,13 +771,7 @@ async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
|||||||
|
|
||||||
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
|
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
|
||||||
if let Some(user) = user {
|
if let Some(user) = user {
|
||||||
if !user.email.trim().is_empty() {
|
return user_account_label_for_audit(user);
|
||||||
return user.email.clone();
|
|
||||||
}
|
|
||||||
if !user.username.trim().is_empty() {
|
|
||||||
return user.username.clone();
|
|
||||||
}
|
|
||||||
return format!("用户 #{}", user.id);
|
|
||||||
}
|
}
|
||||||
identifier
|
identifier
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -783,6 +780,17 @@ fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> Stri
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn user_account_label_for_audit(user: &User) -> String {
|
||||||
|
let username = user.username.trim();
|
||||||
|
let email = user.email.trim();
|
||||||
|
match (username.is_empty(), email.is_empty(), username == email) {
|
||||||
|
(false, false, false) => format!("{username} <{email}>"),
|
||||||
|
(false, _, _) => username.to_string(),
|
||||||
|
(_, false, _) => email.to_string(),
|
||||||
|
_ => format!("用户 #{}", user.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
|
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
headers
|
headers
|
||||||
.get(header::USER_AGENT)
|
.get(header::USER_AGENT)
|
||||||
@@ -793,33 +801,43 @@ fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
|
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
for name in [
|
for name in [
|
||||||
"cf-connecting-ip",
|
"cf-connecting-ip",
|
||||||
"x-real-ip",
|
"x-real-ip",
|
||||||
"x-forwarded-for",
|
"x-forwarded-for",
|
||||||
"forwarded",
|
"forwarded",
|
||||||
] {
|
] {
|
||||||
if let Some(value) = header_ip_value(headers, name) {
|
candidates.extend(header_ip_values(headers, name));
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
if let Some(ipv4) = candidates.iter().find(|value| is_ipv4_address(value)) {
|
||||||
|
return ipv4.clone();
|
||||||
|
}
|
||||||
|
if let Some(first) = candidates.into_iter().next() {
|
||||||
|
return first;
|
||||||
}
|
}
|
||||||
fallback.ip().to_string()
|
fallback.ip().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_ip_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
fn is_ipv4_address(value: &str) -> bool {
|
||||||
let raw = headers.get(name)?.to_str().ok()?.trim();
|
matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
|
||||||
if raw.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if name.eq_ignore_ascii_case("forwarded") {
|
|
||||||
return forwarded_header_ip(raw);
|
|
||||||
}
|
|
||||||
raw.split(',')
|
|
||||||
.filter_map(normalize_ip_token)
|
|
||||||
.next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn forwarded_header_ip(raw: &str) -> Option<String> {
|
fn header_ip_values(headers: &HeaderMap, name: &str) -> Vec<String> {
|
||||||
|
let Some(raw) = headers.get(name).and_then(|value| value.to_str().ok()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if name.eq_ignore_ascii_case("forwarded") {
|
||||||
|
return forwarded_header_ips(raw);
|
||||||
|
}
|
||||||
|
raw.split(',').filter_map(normalize_ip_token).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forwarded_header_ips(raw: &str) -> Vec<String> {
|
||||||
raw.split(',')
|
raw.split(',')
|
||||||
.flat_map(|entry| entry.split(';'))
|
.flat_map(|entry| entry.split(';'))
|
||||||
.filter_map(|part| {
|
.filter_map(|part| {
|
||||||
@@ -829,7 +847,7 @@ fn forwarded_header_ip(raw: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
normalize_ip_token(value)
|
normalize_ip_token(value)
|
||||||
})
|
})
|
||||||
.next()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_ip_token(raw: &str) -> Option<String> {
|
fn normalize_ip_token(raw: &str) -> Option<String> {
|
||||||
@@ -1000,6 +1018,7 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -1131,7 +1150,9 @@ mod tests {
|
|||||||
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
||||||
assert_eq!(event_type, "user_login");
|
assert_eq!(event_type, "user_login");
|
||||||
assert!(title.contains("用户登录成功"));
|
assert!(title.contains("用户登录成功"));
|
||||||
assert!(title.contains("audit@example.test"));
|
assert!(title.contains("Regular User <audit@example.test>"));
|
||||||
|
assert!(message.starts_with("账号=Regular User <audit@example.test>"));
|
||||||
|
assert!(message.contains("邮箱=audit@example.test"));
|
||||||
assert!(message.contains("方式=password"));
|
assert!(message.contains("方式=password"));
|
||||||
assert!(message.contains("IP=203.0.113.8"));
|
assert!(message.contains("IP=203.0.113.8"));
|
||||||
assert_eq!(related_id, user_id);
|
assert_eq!(related_id, user_id);
|
||||||
@@ -1315,10 +1336,43 @@ mod tests {
|
|||||||
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
||||||
assert_eq!(event_type, "user_login");
|
assert_eq!(event_type, "user_login");
|
||||||
assert!(title.contains("用户登录成功"));
|
assert!(title.contains("用户登录成功"));
|
||||||
|
assert!(title.contains("Regular User <key-audit@example.test>"));
|
||||||
|
assert!(message.starts_with("账号=Regular User <key-audit@example.test>"));
|
||||||
assert!(message.contains("方式=api_key"));
|
assert!(message.contains("方式=api_key"));
|
||||||
assert!(message.contains("IP=198.51.100.24"));
|
assert!(message.contains("IP=198.51.100.24"));
|
||||||
assert!(message.contains("密钥=Mobile Login Key"));
|
assert!(message.contains("密钥=Mobile Login Key"));
|
||||||
assert_eq!(related_id, user_id);
|
assert_eq!(related_id, user_id);
|
||||||
assert_eq!(related_type, "user");
|
assert_eq!(related_type, "user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_audit_prefers_ipv4_from_proxy_headers() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let password_hash = hash_password("user-password").expect("password hash");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("IPv4 User")
|
||||||
|
.bind("ipv4@example.test")
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user");
|
||||||
|
|
||||||
|
let Json(_) = login(
|
||||||
|
State(state.clone()),
|
||||||
|
test_addr(),
|
||||||
|
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
|
||||||
|
Json(LoginRequest {
|
||||||
|
email: "ipv4@example.test".into(),
|
||||||
|
password: "user-password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("regular user password login succeeds");
|
||||||
|
|
||||||
|
let (_, _, message, _, _) = latest_audit_event(&state).await;
|
||||||
|
assert!(message.contains("IP=203.0.113.9"), "{message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
use crate::{
|
||||||
|
auth::jwt::validate_jwt,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::payment,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/payment/config", get(get_payment_config))
|
||||||
|
.route("/api/user/payment/orders/{public_id}", get(get_payment_order))
|
||||||
|
.route(
|
||||||
|
"/api/v1/payment/webhook/stripe",
|
||||||
|
post(stripe_webhook),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
|
||||||
|
let auth = headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||||
|
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
|
||||||
|
claims
|
||||||
|
.sub
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_payment_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"payment": payment::public_config(&state.config.payment),
|
||||||
|
"webhooks": {
|
||||||
|
"stripe": "/api/v1/payment/webhook/stripe",
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_payment_order(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(public_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let user_id = current_user_id(&headers, &state)?;
|
||||||
|
let order = payment::fetch_payment_order_by_public_id(&state.db, user_id, public_id.trim()).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"order": order,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stripe_webhook(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||||
|
let webhook_secret = state
|
||||||
|
.config
|
||||||
|
.payment
|
||||||
|
.stripe_webhook_secret
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| AppError::ServiceUnavailable("Stripe webhook secret is not configured".into()))?;
|
||||||
|
let signature = headers
|
||||||
|
.get("stripe-signature")
|
||||||
|
.or_else(|| headers.get("Stripe-Signature"))
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing Stripe signature".into()))?;
|
||||||
|
if !payment::verify_stripe_signature(&body, signature, webhook_secret, 300) {
|
||||||
|
return Err(AppError::Unauthorized("Invalid Stripe signature".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Value = serde_json::from_slice(&body)
|
||||||
|
.map_err(|_| AppError::BadRequest("Invalid Stripe webhook JSON".into()))?;
|
||||||
|
let Some((event_type, session_id, payment_intent)) = payment::stripe_event_checkout_session(&event) else {
|
||||||
|
return Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true }))));
|
||||||
|
};
|
||||||
|
|
||||||
|
match event_type.as_str() {
|
||||||
|
"checkout.session.completed" => {
|
||||||
|
let completion = payment::complete_stripe_order_by_session(
|
||||||
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
payment_intent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"received": true,
|
||||||
|
"completed": true,
|
||||||
|
"order": completion.order,
|
||||||
|
"rechargeRecord": completion.recharge_record,
|
||||||
|
"creditedKeyId": completion.credited_key_id,
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"checkout.session.expired" => {
|
||||||
|
let order = payment::mark_stripe_order_failed_by_session(&state.db, &session_id).await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"received": true,
|
||||||
|
"failed": order.is_some(),
|
||||||
|
"order": order,
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true })))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripe_signature_verification_matches_route_header_shape() {
|
||||||
|
let payload = br#"{"type":"checkout.session.completed"}"#;
|
||||||
|
let secret = "whsec_route_test";
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||||
|
mac.update(format!("{timestamp}.{}", String::from_utf8_lossy(payload)).as_bytes());
|
||||||
|
let signature = hex::encode(mac.finalize().into_bytes());
|
||||||
|
let header = HeaderValue::from_str(&format!("t={timestamp},v1={signature}")).unwrap();
|
||||||
|
|
||||||
|
assert!(payment::verify_stripe_signature(
|
||||||
|
payload,
|
||||||
|
header.to_str().unwrap(),
|
||||||
|
secret,
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod comfyui_process;
|
|||||||
pub mod cron;
|
pub mod cron;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
pub mod payment;
|
||||||
pub mod register_captcha;
|
pub mod register_captcha;
|
||||||
pub mod registration_limiter;
|
pub mod registration_limiter;
|
||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,7 @@ describe('ProgramLogs status label', () => {
|
|||||||
expect(targetRow).toBeTruthy();
|
expect(targetRow).toBeTruthy();
|
||||||
|
|
||||||
const tds = targetRow!.findAll((node) => node.type === 'td');
|
const tds = targetRow!.findAll((node) => node.type === 'td');
|
||||||
const statusCell = tds[5];
|
const statusCell = tds[7];
|
||||||
expect(collectText(statusCell).trim()).toBe('成功');
|
expect(collectText(statusCell).trim()).toBe('成功');
|
||||||
const statusBadge = statusCell.find((node) => node.type === 'span');
|
const statusBadge = statusCell.find((node) => node.type === 'span');
|
||||||
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
||||||
@@ -131,12 +131,51 @@ describe('ProgramLogs status label', () => {
|
|||||||
expect(targetRow).toBeTruthy();
|
expect(targetRow).toBeTruthy();
|
||||||
|
|
||||||
const tds = targetRow!.findAll((node) => node.type === 'td');
|
const tds = targetRow!.findAll((node) => node.type === 'td');
|
||||||
const statusCell = tds[5];
|
const statusCell = tds[7];
|
||||||
expect(collectText(statusCell).trim()).toBe('成功');
|
expect(collectText(statusCell).trim()).toBe('成功');
|
||||||
const statusBadge = statusCell.find((node) => node.type === 'span');
|
const statusBadge = statusCell.find((node) => node.type === 'span');
|
||||||
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
expect(String(statusBadge.props.className || '')).toContain('badge-success');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows login account and client IP as first-class columns', async () => {
|
||||||
|
apiMock.getEvents.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
type: 'user_login',
|
||||||
|
title: '用户登录成功:姜先生 <user@example.test>',
|
||||||
|
account: '姜先生 <user@example.test>',
|
||||||
|
clientIp: '203.0.113.8',
|
||||||
|
message: '账号=姜先生 <user@example.test> · 邮箱=user@example.test · 用户ID=12 · 方式=api_key · IP=203.0.113.8',
|
||||||
|
level: 'info',
|
||||||
|
read: false,
|
||||||
|
relatedType: 'user',
|
||||||
|
relatedId: 12,
|
||||||
|
createdAt: '2026-06-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/events']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<ProgramLogs />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rows = root!.root.findAll((node) => node.type === 'tr');
|
||||||
|
const targetRow = rows.find((row) => collectText(row).includes('用户登录成功'));
|
||||||
|
expect(targetRow).toBeTruthy();
|
||||||
|
|
||||||
|
const tds = targetRow!.findAll((node) => node.type === 'td');
|
||||||
|
expect(collectText(tds[4]!).trim()).toBe('姜先生 <user@example.test>');
|
||||||
|
expect(collectText(tds[5]!).trim()).toBe('203.0.113.8');
|
||||||
|
expect(collectText(targetRow!)).toContain('IP=203.0.113.8');
|
||||||
|
});
|
||||||
|
|
||||||
it('opens related trace and route targets from desktop rows', async () => {
|
it('opens related trace and route targets from desktop rows', async () => {
|
||||||
apiMock.getEvents.mockResolvedValue([
|
apiMock.getEvents.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type ProgramEvent = {
|
|||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
account?: string | null;
|
||||||
|
clientIp?: string | null;
|
||||||
level: 'info' | 'warning' | 'error';
|
level: 'info' | 'warning' | 'error';
|
||||||
read: boolean;
|
read: boolean;
|
||||||
relatedId?: number | null;
|
relatedId?: number | null;
|
||||||
@@ -462,6 +464,12 @@ export default function ProgramLogs() {
|
|||||||
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{typeLabel(row.type)}</span>} />
|
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{typeLabel(row.type)}</span>} />
|
||||||
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
|
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
|
||||||
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
|
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
|
||||||
|
{row.type === 'user_login' && (
|
||||||
|
<>
|
||||||
|
<MobileField label="账号" value={row.account || '-'} />
|
||||||
|
<MobileField label="IPv4/IP" value={row.clientIp || '-'} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MobileField label="内容" value={row.message || '-'} stacked />
|
<MobileField label="内容" value={row.message || '-'} stacked />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
@@ -482,6 +490,8 @@ export default function ProgramLogs() {
|
|||||||
<col style={{ width: 90 }} />
|
<col style={{ width: 90 }} />
|
||||||
<col style={{ width: 90 }} />
|
<col style={{ width: 90 }} />
|
||||||
<col style={{ width: 260 }} />
|
<col style={{ width: 260 }} />
|
||||||
|
<col style={{ width: 220 }} />
|
||||||
|
<col style={{ width: 150 }} />
|
||||||
<col />
|
<col />
|
||||||
<col style={{ width: 110 }} />
|
<col style={{ width: 110 }} />
|
||||||
<col style={{ width: 180 }} />
|
<col style={{ width: 180 }} />
|
||||||
@@ -492,6 +502,8 @@ export default function ProgramLogs() {
|
|||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>级别</th>
|
<th>级别</th>
|
||||||
<th>标题</th>
|
<th>标题</th>
|
||||||
|
<th>账号</th>
|
||||||
|
<th>IPv4/IP</th>
|
||||||
<th>内容</th>
|
<th>内容</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th style={{ textAlign: 'right' }}>操作</th>
|
<th style={{ textAlign: 'right' }}>操作</th>
|
||||||
@@ -519,6 +531,12 @@ export default function ProgramLogs() {
|
|||||||
<td className="program-logs-title-cell">
|
<td className="program-logs-title-cell">
|
||||||
{row.title || '-'}
|
{row.title || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="program-logs-account-cell">
|
||||||
|
{row.account || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="program-logs-ip-cell">
|
||||||
|
{row.clientIp || '-'}
|
||||||
|
</td>
|
||||||
<td className="program-logs-content-cell">
|
<td className="program-logs-content-cell">
|
||||||
{row.message || '-'}
|
{row.message || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import { USER_RECHARGE_CONFIG, isUserRechargeOptionPayable } from '../userRecharge.js';
|
import { USER_RECHARGE_CONFIG, isUserRechargeOptionPayable } from '../userRecharge.js';
|
||||||
import UserRecharge from './UserRecharge.js';
|
import UserRecharge from './UserRecharge.js';
|
||||||
|
|
||||||
const { userApiMock } = vi.hoisted(() => ({
|
const { TEST_CHECKOUT_URL, userApiMock } = vi.hoisted(() => ({
|
||||||
|
TEST_CHECKOUT_URL: 'https://checkout.comfy.org/c/pay/session_50',
|
||||||
userApiMock: {
|
userApiMock: {
|
||||||
getRechargeRecords: vi.fn(),
|
getRechargeRecords: vi.fn(),
|
||||||
createRechargeCheckout: vi.fn(),
|
createRechargeCheckout: vi.fn(),
|
||||||
@@ -33,7 +34,7 @@ describe('UserRecharge page', () => {
|
|||||||
title: '团队高频',
|
title: '团队高频',
|
||||||
amountUsd: 50,
|
amountUsd: 50,
|
||||||
provider: 'comfy_checkout',
|
provider: 'comfy_checkout',
|
||||||
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl,
|
checkoutUrl: TEST_CHECKOUT_URL,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: '2026-06-09T08:00:00Z',
|
createdAt: '2026-06-09T08:00:00Z',
|
||||||
}],
|
}],
|
||||||
@@ -51,7 +52,7 @@ describe('UserRecharge page', () => {
|
|||||||
});
|
});
|
||||||
userApiMock.createRechargeCheckout.mockResolvedValue({
|
userApiMock.createRechargeCheckout.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl,
|
checkoutUrl: TEST_CHECKOUT_URL,
|
||||||
record: {
|
record: {
|
||||||
id: 2,
|
id: 2,
|
||||||
publicId: 'rch_created',
|
publicId: 'rch_created',
|
||||||
@@ -59,7 +60,7 @@ describe('UserRecharge page', () => {
|
|||||||
title: '团队高频',
|
title: '团队高频',
|
||||||
amountUsd: 50,
|
amountUsd: 50,
|
||||||
provider: 'comfy_checkout',
|
provider: 'comfy_checkout',
|
||||||
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl,
|
checkoutUrl: TEST_CHECKOUT_URL,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: '2026-06-09T09:00:00Z',
|
createdAt: '2026-06-09T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -73,7 +74,7 @@ describe('UserRecharge page', () => {
|
|||||||
title: '团队高频',
|
title: '团队高频',
|
||||||
amountUsd: 50,
|
amountUsd: 50,
|
||||||
provider: 'comfy_checkout',
|
provider: 'comfy_checkout',
|
||||||
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl,
|
checkoutUrl: TEST_CHECKOUT_URL,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
createdAt: '2026-06-09T08:00:00Z',
|
createdAt: '2026-06-09T08:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -86,6 +87,7 @@ describe('UserRecharge page', () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
open: vi.fn(() => ({
|
open: vi.fn(() => ({
|
||||||
|
opener: {},
|
||||||
location: { href: '' },
|
location: { href: '' },
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
})),
|
})),
|
||||||
@@ -133,8 +135,8 @@ describe('UserRecharge page', () => {
|
|||||||
expect(text).toContain('rch_test_1');
|
expect(text).toContain('rch_test_1');
|
||||||
expect(text).toContain('付款后由管理员核对到账。');
|
expect(text).toContain('付款后由管理员核对到账。');
|
||||||
expect(optionButtons).toHaveLength(payableOptions.length);
|
expect(optionButtons).toHaveLength(payableOptions.length);
|
||||||
expect(disabledOptions).toHaveLength(0);
|
|
||||||
expect(text).not.toContain('待配置');
|
expect(text).not.toContain('待配置');
|
||||||
|
expect(disabledOptions).toHaveLength(0);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
primaryButton.props.onClick();
|
primaryButton.props.onClick();
|
||||||
@@ -146,8 +148,9 @@ describe('UserRecharge page', () => {
|
|||||||
optionId: USER_RECHARGE_CONFIG.defaultOption.id,
|
optionId: USER_RECHARGE_CONFIG.defaultOption.id,
|
||||||
amountUsd: USER_RECHARGE_CONFIG.defaultOption.amountUsd,
|
amountUsd: USER_RECHARGE_CONFIG.defaultOption.amountUsd,
|
||||||
});
|
});
|
||||||
expect((window.open as any)).toHaveBeenCalledWith('about:blank', '_blank', 'noopener,noreferrer');
|
expect((window.open as any)).toHaveBeenCalledWith('about:blank', '_blank');
|
||||||
expect((window.open as any).mock.results[0].value.location.href).toBe(USER_RECHARGE_CONFIG.checkoutUrl);
|
expect((window.open as any).mock.results[0].value.location.href).toBe(TEST_CHECKOUT_URL);
|
||||||
|
expect((window.open as any).mock.results[0].value.opener).toBeNull();
|
||||||
expect((window.location.assign as any)).not.toHaveBeenCalled();
|
expect((window.location.assign as any)).not.toHaveBeenCalled();
|
||||||
const externalCheckoutLinks = links.filter((link) => (
|
const externalCheckoutLinks = links.filter((link) => (
|
||||||
String(link.props.href || '').startsWith('https://checkout.comfy.org/c/pay/')
|
String(link.props.href || '').startsWith('https://checkout.comfy.org/c/pay/')
|
||||||
@@ -191,4 +194,38 @@ describe('UserRecharge page', () => {
|
|||||||
root!.unmount();
|
root!.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to current-tab navigation when the checkout popup is blocked', async () => {
|
||||||
|
(window.open as any).mockReturnValueOnce(null);
|
||||||
|
let root: ReturnType<typeof create>;
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/user/recharge']}>
|
||||||
|
<UserRecharge />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const primaryButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& collectText(node).includes('创建记录并支付')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
primaryButton.props.onClick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window.open as any)).toHaveBeenCalledWith('about:blank', '_blank');
|
||||||
|
expect(window.location.assign).toHaveBeenCalledWith(TEST_CHECKOUT_URL);
|
||||||
|
} finally {
|
||||||
|
root!.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ function statusText(status: string) {
|
|||||||
return '待确认';
|
return '待确认';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reserveCheckoutWindow(): Window | null {
|
||||||
|
const checkoutWindow = window.open('about:blank', '_blank');
|
||||||
|
if (checkoutWindow) {
|
||||||
|
try {
|
||||||
|
checkoutWindow.opener = null;
|
||||||
|
} catch {
|
||||||
|
// Ignore browsers that do not allow mutating the opener reference.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return checkoutWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateCheckoutWindow(checkoutWindow: Window | null, checkoutUrl: string) {
|
||||||
|
if (checkoutWindow) {
|
||||||
|
checkoutWindow.location.href = checkoutUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign(checkoutUrl);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserRecharge() {
|
export default function UserRecharge() {
|
||||||
const recharge = USER_RECHARGE_CONFIG;
|
const recharge = USER_RECHARGE_CONFIG;
|
||||||
const [records, setRecords] = useState<UserRechargeRecord[]>([]);
|
const [records, setRecords] = useState<UserRechargeRecord[]>([]);
|
||||||
@@ -61,7 +81,7 @@ export default function UserRecharge() {
|
|||||||
if (pendingOptionId) return;
|
if (pendingOptionId) return;
|
||||||
setPendingOptionId(option.id);
|
setPendingOptionId(option.id);
|
||||||
setError('');
|
setError('');
|
||||||
const checkoutWindow = window.open('about:blank', '_blank', 'noopener,noreferrer');
|
const checkoutWindow = reserveCheckoutWindow();
|
||||||
try {
|
try {
|
||||||
const payload = await userApi.createRechargeCheckout({
|
const payload = await userApi.createRechargeCheckout({
|
||||||
optionId: option.id,
|
optionId: option.id,
|
||||||
@@ -76,11 +96,7 @@ export default function UserRecharge() {
|
|||||||
if (!checkoutUrl) {
|
if (!checkoutUrl) {
|
||||||
throw new Error('支付链接未返回,请稍后重试。');
|
throw new Error('支付链接未返回,请稍后重试。');
|
||||||
}
|
}
|
||||||
if (checkoutWindow) {
|
navigateCheckoutWindow(checkoutWindow, checkoutUrl);
|
||||||
checkoutWindow.location.href = checkoutUrl;
|
|
||||||
} else {
|
|
||||||
window.location.assign(checkoutUrl);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
try {
|
try {
|
||||||
checkoutWindow?.close();
|
checkoutWindow?.close();
|
||||||
|
|||||||
@@ -1,40 +1,46 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
USER_RECHARGE_CHECKOUT_URL,
|
|
||||||
isUserRechargeOptionPayable,
|
isUserRechargeOptionPayable,
|
||||||
resolveUserRechargeConfig,
|
resolveUserRechargeConfig,
|
||||||
} from './userRecharge.js';
|
} from './userRecharge.js';
|
||||||
|
|
||||||
describe('user recharge config', () => {
|
describe('user recharge config', () => {
|
||||||
it('uses the Comfy checkout link by default', () => {
|
it('uses amount-specific recharge options by default without a fixed checkout url', () => {
|
||||||
const config = resolveUserRechargeConfig({});
|
const config = resolveUserRechargeConfig({});
|
||||||
|
|
||||||
expect(config.checkoutUrl).toBe(USER_RECHARGE_CHECKOUT_URL);
|
expect(config.checkoutUrl).toBe('');
|
||||||
expect(config.checkoutUrl).toContain('https://checkout.comfy.org/c/pay/');
|
|
||||||
expect(config.providerLabel).toBe('Comfy Checkout');
|
expect(config.providerLabel).toBe('Comfy Checkout');
|
||||||
expect(config.opensInNewTab).toBe(true);
|
expect(config.opensInNewTab).toBe(true);
|
||||||
|
expect(config.defaultOption.id).toBe('usd-50');
|
||||||
expect(config.defaultOption.label).toBe('$50');
|
expect(config.defaultOption.label).toBe('$50');
|
||||||
|
expect(config.defaultOption.amountUsd).toBe(50);
|
||||||
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
||||||
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual([
|
||||||
expect(config.options.every((option) => option.checkoutUrl === USER_RECHARGE_CHECKOUT_URL)).toBe(true);
|
'$5',
|
||||||
|
'$10',
|
||||||
|
'$20',
|
||||||
|
'$50',
|
||||||
|
]);
|
||||||
|
expect(config.options.every((option) => !option.disabled && !option.checkoutUrl)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows deployment-specific checkout URL overrides', () => {
|
it('does not let a single checkout URL override price every amount', () => {
|
||||||
const config = resolveUserRechargeConfig({
|
const config = resolveUserRechargeConfig({
|
||||||
VITE_USER_RECHARGE_CHECKOUT_URL: ' https://pay.example.test/session/123 ',
|
VITE_USER_RECHARGE_CHECKOUT_URL: ' https://pay.example.test/session/123 ',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config.checkoutUrl).toBe('https://pay.example.test/session/123');
|
expect(config.checkoutUrl).toBe('');
|
||||||
|
expect(config.defaultOption.id).toBe('usd-50');
|
||||||
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
||||||
expect(config.options.every((option) => option.checkoutUrl === 'https://pay.example.test/session/123')).toBe(true);
|
expect(config.options.every((option) => !option.checkoutUrl)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the default checkout URL for blank overrides', () => {
|
it('ignores blank single checkout URL overrides', () => {
|
||||||
const config = resolveUserRechargeConfig({
|
const config = resolveUserRechargeConfig({
|
||||||
VITE_USER_RECHARGE_CHECKOUT_URL: ' ',
|
VITE_USER_RECHARGE_CHECKOUT_URL: ' ',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config.checkoutUrl).toBe(USER_RECHARGE_CHECKOUT_URL);
|
expect(config.checkoutUrl).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports deployment-specific checkout links per recharge amount', () => {
|
it('supports deployment-specific checkout links per recharge amount', () => {
|
||||||
@@ -81,11 +87,17 @@ describe('user recharge config', () => {
|
|||||||
expect(config.defaultOption.label).toBe('$50');
|
expect(config.defaultOption.label).toBe('$50');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid option JSON and keeps default amounts', () => {
|
it('ignores invalid option JSON and keeps amount-specific default behavior', () => {
|
||||||
const config = resolveUserRechargeConfig({
|
const config = resolveUserRechargeConfig({
|
||||||
VITE_USER_RECHARGE_OPTIONS: 'not-json',
|
VITE_USER_RECHARGE_OPTIONS: 'not-json',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
||||||
|
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual([
|
||||||
|
'$5',
|
||||||
|
'$10',
|
||||||
|
'$20',
|
||||||
|
'$50',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-29
@@ -1,5 +1,3 @@
|
|||||||
const DEFAULT_USER_RECHARGE_CHECKOUT_URL = 'https://checkout.comfy.org/c/pay/cs_live_b1r35y0Hs4XhUUkxJRjr9EnD96jFyWdocmfVBEn0RpOzFeKDMLDaqNEFho#fid1d2BpamRhQ2prcSc%2FJ0xrcWB3JyknZ2p3YWB3VnF8aWAnPydhYGNkcGlxJyknYnBkZmRoamlgU2R3bGRrcSc%2FJ2Zqa3F3amknKSdkdWxOYHwnPyd1blppbHNgWjA0SDRhSU9Bd1dTfFFUaEB%2FdXBvRF9qXE1KRHZpS2BBTWBBQT1nb1NuM2M2YGthVmFWY0QwSW1pb09Hd0xfPGZ0ZFc2fTNcQW1HdUROSF18UElWbmY0YkJkNTUwYlNxU2czRCcpJ2N3amhWYHdzYHcnP3F3cGApJ2dkZm5id2pwa2FGamlqdyc%2FJyY3NDQ8NzInKSdpZHxqcHFRfHVgJz8naHBpcWxabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl';
|
|
||||||
|
|
||||||
type UserRechargeEnv = {
|
type UserRechargeEnv = {
|
||||||
VITE_USER_RECHARGE_CHECKOUT_URL?: string;
|
VITE_USER_RECHARGE_CHECKOUT_URL?: string;
|
||||||
VITE_USER_RECHARGE_OPTIONS?: string;
|
VITE_USER_RECHARGE_OPTIONS?: string;
|
||||||
@@ -17,7 +15,6 @@ export type UserRechargeOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UserRechargeCheckoutOption = UserRechargeOption & {
|
export type UserRechargeCheckoutOption = UserRechargeOption & {
|
||||||
checkoutUrl: string;
|
|
||||||
disabled?: false;
|
disabled?: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,19 +28,20 @@ export type UserRechargeConfig = {
|
|||||||
|
|
||||||
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
||||||
|
|
||||||
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeOption[] = [
|
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
|
||||||
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
|
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
|
||||||
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
|
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10 },
|
||||||
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
|
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20 },
|
||||||
{ id: 'usd-50', label: '$50', title: '团队高频', amountUsd: 50, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL, recommended: true },
|
{ id: 'usd-50', label: '$50', title: '团队高频', amountUsd: 50, recommended: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
function checkoutUrlOverrideFromEnv(env: UserRechargeEnv) {
|
function hasValidAmount(option: UserRechargeOption) {
|
||||||
return (env.VITE_USER_RECHARGE_CHECKOUT_URL || '').trim();
|
return typeof option.amountUsd === 'number' && Number.isFinite(option.amountUsd) && option.amountUsd > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUserRechargeOptionPayable(option: UserRechargeOption): option is UserRechargeCheckoutOption {
|
export function isUserRechargeOptionPayable(option: UserRechargeOption): option is UserRechargeCheckoutOption {
|
||||||
return Boolean(option.checkoutUrl && !option.disabled);
|
const checkoutUrl = String(option.checkoutUrl || '').trim();
|
||||||
|
return !option.disabled && (Boolean(checkoutUrl) || hasValidAmount(option));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
|
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
|
||||||
@@ -84,36 +82,24 @@ function optionsFromEnv(raw: string | undefined): UserRechargeOption[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultOptionsFor(checkoutUrl: string, hasCheckoutOverride: boolean): UserRechargeOption[] {
|
function defaultOptions(): UserRechargeCheckoutOption[] {
|
||||||
if (hasCheckoutOverride) {
|
|
||||||
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({
|
|
||||||
...item,
|
|
||||||
checkoutUrl,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({ ...item }));
|
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({ ...item }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveUserRechargeConfig(env: UserRechargeEnv = import.meta.env as UserRechargeEnv): UserRechargeConfig {
|
export function resolveUserRechargeConfig(env: UserRechargeEnv = import.meta.env as UserRechargeEnv): UserRechargeConfig {
|
||||||
const checkoutUrlOverride = checkoutUrlOverrideFromEnv(env);
|
|
||||||
const fallbackCheckoutUrl = checkoutUrlOverride || DEFAULT_USER_RECHARGE_CHECKOUT_URL;
|
|
||||||
const customOptions = optionsFromEnv(env.VITE_USER_RECHARGE_OPTIONS);
|
const customOptions = optionsFromEnv(env.VITE_USER_RECHARGE_OPTIONS);
|
||||||
const options = customOptions.length > 0
|
const options = customOptions.length > 0
|
||||||
? customOptions
|
? customOptions
|
||||||
: defaultOptionsFor(fallbackCheckoutUrl, Boolean(checkoutUrlOverride));
|
: defaultOptions();
|
||||||
const payableOptions = options.filter(isUserRechargeOptionPayable);
|
const payableOptions = options.filter(isUserRechargeOptionPayable);
|
||||||
|
const fallbackOptions = defaultOptions();
|
||||||
const defaultOption = payableOptions.find((item) => item.recommended)
|
const defaultOption = payableOptions.find((item) => item.recommended)
|
||||||
|| payableOptions[0]
|
|| payableOptions[0]
|
||||||
|| {
|
|| fallbackOptions.find((item) => item.recommended)
|
||||||
id: 'default-checkout',
|
|| fallbackOptions[0]!;
|
||||||
label: '充值',
|
|
||||||
title: '打开支付页',
|
|
||||||
checkoutUrl: fallbackCheckoutUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checkoutUrl: defaultOption.checkoutUrl,
|
checkoutUrl: defaultOption.checkoutUrl || '',
|
||||||
defaultOption,
|
defaultOption,
|
||||||
options,
|
options,
|
||||||
providerLabel: 'Comfy Checkout',
|
providerLabel: 'Comfy Checkout',
|
||||||
|
|||||||
Reference in New Issue
Block a user