P0 - Panic 风险修复: - payment.rs: unwrap() → let-else safe handling - payment.rs: get_jwt_secret() expect → Result/AppError - auth.rs: openid 切片添加 len >= 8 守卫 - main.rs: 启动时 expect → unwrap_or_else 描述性 panic - main.rs: Directive::from_str 添加 fallback P1 - 逻辑/安全修复: - payment.rs: urlencoding() + 解码 bug 修复 (移除 had_escape) - payment.rs: Mock 支付添加 check_mock_payment_allowed 检查 - db.rs: 永久会员 NULL → 2099-12-31 一致化 - user.rs: 维护模式添加安全说明注释 - 自动清理 unused_variables 警告 (_is_mobile) P2 - 错误吞没修复: - main.rs: 3 处定时任务 let _ = → if let Err = tracing::error! - db.rs + admin.rs: 7 处通知/审计日志 let _ = → tracing::warn! - auth.rs: refresh token 保存 add warn 日志 P3 - 死代码清理: - models.rs: 移除 TokenResponse (dead) - models.rs: 移除 AppState 中 5 个未使用字段 (env var 直接读取) - error.rs: 移除 3 个 dead ErrorResponse 方法 - rate_limiter.rs: extract_client_ip_from_header → #[cfg(test)] - models.rs: 注释 typo fix (user_ytpe → user_type) - db.rs: RefreshToken 添加 deserialization 注释 Shell 脚本修复: - deploy.sh: run_migrations 移到 restart_service 之前 - test.sh: 移除 EXIT trap 覆盖; heredoc 引号修复; 维护模式添加 restart - common.sh: mock_key 添加 sed 转义 (防 / & 注入) 验证: cargo check 0 warnings, 8 tests passed
This commit is contained in:
@@ -183,8 +183,8 @@ upload_script "清理脚本" scripts/cleanup_refresh_tokens.sh scripts/cleanup_r
|
|||||||
upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh
|
upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh
|
||||||
|
|
||||||
deploy_service_file
|
deploy_service_file
|
||||||
|
run_migrations # Moved BEFORE restart
|
||||||
restart_service
|
restart_service
|
||||||
run_migrations
|
|
||||||
|
|
||||||
if run_tests; then
|
if run_tests; then
|
||||||
log_to_file "DEPLOY success"
|
log_to_file "DEPLOY success"
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ deploy_service_file() {
|
|||||||
local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0")
|
local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0")
|
||||||
if [ "$has_mock" = "0" ]; then
|
if [ "$has_mock" = "0" ]; then
|
||||||
local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)")
|
local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)")
|
||||||
remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${mock_key}' /etc/systemd/system/${SERVICE_NAME}"
|
local escaped_key; escaped_key=$(echo "$mock_key" | sed 's/[\/&]/\\&/g')
|
||||||
|
remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${escaped_key}' /etc/systemd/system/${SERVICE_NAME}"
|
||||||
log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}"
|
log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
14
lib/test.sh
14
lib/test.sh
@@ -186,16 +186,6 @@ test_migration_status() {
|
|||||||
test_payment_flow() {
|
test_payment_flow() {
|
||||||
log_info "开始支付链路测试..."
|
log_info "开始支付链路测试..."
|
||||||
|
|
||||||
# 清理 trap:测试失败时清理
|
|
||||||
local test_cleanup_done=false
|
|
||||||
cleanup_payment_test() {
|
|
||||||
if [ "$test_cleanup_done" = false ]; then
|
|
||||||
cleanup_test_user
|
|
||||||
test_cleanup_done=true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup_payment_test EXIT
|
|
||||||
|
|
||||||
# 阶段 1:检查订单到期时间计算与套餐天数是否一致
|
# 阶段 1:检查订单到期时间计算与套餐天数是否一致
|
||||||
# 通过 SQL 直接创建测试订单(避免 JWT 依赖),验证 order.expires_at 间隔
|
# 通过 SQL 直接创建测试订单(避免 JWT 依赖),验证 order.expires_at 间隔
|
||||||
local uuid_prefix
|
local uuid_prefix
|
||||||
@@ -319,7 +309,7 @@ test_payment_dedup() {
|
|||||||
local all_ok=true
|
local all_ok=true
|
||||||
|
|
||||||
# 1. 在服务器上生成 SQL 文件(避免 stdin 管道问题)
|
# 1. 在服务器上生成 SQL 文件(避免 stdin 管道问题)
|
||||||
remote "cat > ${sql_file} << 'SQLEOF'
|
remote "cat > ${sql_file} << SQLEOF
|
||||||
INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no1}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
|
INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no1}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
|
||||||
INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no2}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
|
INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no2}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
|
||||||
SQLEOF
|
SQLEOF
|
||||||
@@ -357,7 +347,7 @@ test_payment_maintenance() {
|
|||||||
# 启用维护模式
|
# 启用维护模式
|
||||||
remote "sed -i '/^Environment=PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/${SERVICE_NAME}
|
remote "sed -i '/^Environment=PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/${SERVICE_NAME}
|
||||||
sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/${SERVICE_NAME}
|
sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/${SERVICE_NAME}
|
||||||
systemctl daemon-reload && sleep 1" 2>/dev/null || true
|
systemctl daemon-reload && systemctl restart ${SERVICE_NAME} && sleep 3" 2>/dev/null || true
|
||||||
|
|
||||||
# 测试 quota 返回 unlimited
|
# 测试 quota 返回 unlimited
|
||||||
local quota_resp
|
local quota_resp
|
||||||
|
|||||||
72
src/db.rs
72
src/db.rs
@@ -700,7 +700,7 @@ pub async fn confirm_payment_order_by_orderno(
|
|||||||
is_member = true,
|
is_member = true,
|
||||||
membership_expires_at =
|
membership_expires_at =
|
||||||
CASE
|
CASE
|
||||||
WHEN uo.package_type = 'permanent' THEN NULL
|
WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
|
||||||
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
|
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
|
||||||
CASE
|
CASE
|
||||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||||
@@ -727,9 +727,13 @@ pub async fn confirm_payment_order_by_orderno(
|
|||||||
|
|
||||||
// 审计日志
|
// 审计日志
|
||||||
if let Some(uid) = user_id {
|
if let Some(uid) = user_id {
|
||||||
let _ = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await;
|
if let Err(e) = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await {
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
// 发送支付成功通知
|
// 发送支付成功通知
|
||||||
let _ = insert_payment_notification(pool, uid, order_no).await;
|
if let Err(e) = insert_payment_notification(pool, uid, order_no).await {
|
||||||
|
tracing::warn!("插入支付通知失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// 验证会员到期时间是否正常
|
// 验证会员到期时间是否正常
|
||||||
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
|
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
|
||||||
@@ -808,7 +812,9 @@ pub async fn refund_payment_order(
|
|||||||
|
|
||||||
// 审计日志
|
// 审计日志
|
||||||
let detail = if other_active.0 == 0 { "退款,会员已撤销" } else { "退款,有其他有效订单,保留会员" };
|
let detail = if other_active.0 == 0 { "退款,会员已撤销" } else { "退款,有其他有效订单,保留会员" };
|
||||||
let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await;
|
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await {
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -901,14 +907,14 @@ pub async fn set_weather_favorite(
|
|||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow)]
|
||||||
pub struct RefreshToken {
|
pub struct RefreshToken {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // sqlx deserialization only; not read directly
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub user_id: i32,
|
pub user_id: i32,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // sqlx deserialization only
|
||||||
pub token: String,
|
pub token: String,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // sqlx deserialization only
|
||||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)] // sqlx deserialization only
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,17 +1074,17 @@ pub async fn admin_force_confirm_order(
|
|||||||
UPDATE users SET
|
UPDATE users SET
|
||||||
is_member = true,
|
is_member = true,
|
||||||
membership_expires_at =
|
membership_expires_at =
|
||||||
CASE
|
CASE
|
||||||
WHEN uo.package_type = 'permanent' THEN NULL
|
WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
|
||||||
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
|
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
|
||||||
CASE
|
CASE
|
||||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||||
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
|
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
|
||||||
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
|
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
|
||||||
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
|
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
|
||||||
ELSE INTERVAL '0 days'
|
ELSE INTERVAL '0 days'
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
FROM updated_order uo
|
FROM updated_order uo
|
||||||
WHERE users.id = $2
|
WHERE users.id = $2
|
||||||
RETURNING users.membership_expires_at
|
RETURNING users.membership_expires_at
|
||||||
@@ -1091,7 +1097,7 @@ pub async fn admin_force_confirm_order(
|
|||||||
.map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?;
|
.map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?;
|
||||||
|
|
||||||
// 写审计日志
|
// 写审计日志
|
||||||
let _ = insert_payment_audit_log(
|
if let Err(e) = insert_payment_audit_log(
|
||||||
pool,
|
pool,
|
||||||
order_no,
|
order_no,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -1099,7 +1105,10 @@ pub async fn admin_force_confirm_order(
|
|||||||
Some(admin_user_id),
|
Some(admin_user_id),
|
||||||
Some("管理员强制确认支付"),
|
Some("管理员强制确认支付"),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// 验证会员到期时间是否正常
|
// 验证会员到期时间是否正常
|
||||||
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
|
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
|
||||||
@@ -1109,7 +1118,7 @@ pub async fn admin_force_confirm_order(
|
|||||||
result
|
result
|
||||||
} else {
|
} else {
|
||||||
// 订单已处理,只写日志
|
// 订单已处理,只写日志
|
||||||
let _ = insert_payment_audit_log(
|
if let Err(e) = insert_payment_audit_log(
|
||||||
pool,
|
pool,
|
||||||
order_no,
|
order_no,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -1117,7 +1126,10 @@ pub async fn admin_force_confirm_order(
|
|||||||
Some(admin_user_id),
|
Some(admin_user_id),
|
||||||
Some(&format!("订单状态为 {},跳过确认", status)),
|
Some(&format!("订单状态为 {},跳过确认", status)),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// 返回当前到期时间
|
// 返回当前到期时间
|
||||||
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
|
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
|
||||||
@@ -1182,8 +1194,11 @@ pub async fn cancel_payment_order(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?;
|
.map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?;
|
||||||
|
|
||||||
let _ = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id),
|
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id),
|
||||||
Some("用户主动取消待支付订单")).await;
|
Some("用户主动取消待支付订单")).await
|
||||||
|
{
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1256,8 +1271,11 @@ pub async fn admin_refund_order(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" };
|
let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" };
|
||||||
let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded",
|
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded",
|
||||||
Some(admin_user_id), Some(detail)).await;
|
Some(admin_user_id), Some(detail)).await
|
||||||
|
{
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/error.rs
27
src/error.rs
@@ -11,15 +11,6 @@ pub struct ErrorResponse<T = ()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T> ErrorResponse<T> {
|
impl<T> ErrorResponse<T> {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn success(data: T) -> Self {
|
|
||||||
Self {
|
|
||||||
success: true,
|
|
||||||
data: Some(data),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(msg: impl Into<String>) -> Self {
|
pub fn error(msg: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -29,24 +20,6 @@ impl<T> ErrorResponse<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Serialize> ErrorResponse<T> {
|
|
||||||
#[allow(dead_code, clippy::wrong_self_convention)]
|
|
||||||
pub fn to_json_response(self) -> HttpResponse {
|
|
||||||
HttpResponse::Ok().json(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ErrorResponse<()> {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn to_error_response(&self, status: StatusCode) -> HttpResponse {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"success": false,
|
|
||||||
"error": self.error.clone().unwrap_or_else(|| "Unknown error".to_string())
|
|
||||||
});
|
|
||||||
HttpResponse::build(status).json(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ pub async fn admin_update_user_payment(
|
|||||||
// 更新用户付费状态
|
// 更新用户付费状态
|
||||||
db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_member, membership_expires_at).await?;
|
db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_member, membership_expires_at).await?;
|
||||||
// 审计日志
|
// 审计日志
|
||||||
let _ = db::insert_payment_audit_log(
|
if let Err(e) = db::insert_payment_audit_log(
|
||||||
pool.get_ref(), "ADMIN_MANUAL", target_user_id, "admin_revoke",
|
pool.get_ref(), "ADMIN_MANUAL", target_user_id, "admin_revoke",
|
||||||
Some(claims.user_id),
|
Some(claims.user_id),
|
||||||
Some(&format!("管理员手动更新付费状态: is_member={}, expires_at={:?}", body.is_member, body.membership_expires_at)),
|
Some(&format!("管理员手动更新付费状态: is_member={}, expires_at={:?}", body.is_member, body.membership_expires_at)),
|
||||||
).await;
|
).await {
|
||||||
|
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||||
|
}
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "用户付费状态已更新"
|
"message": "用户付费状态已更新"
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ pub async fn login(
|
|||||||
|
|
||||||
let openid = match wechat_data.openid {
|
let openid = match wechat_data.openid {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
let masked_openid = format!("{}***{}", &id[0..4], &id[id.len() - 4..]);
|
let masked_openid = if id.len() >= 8 {
|
||||||
|
format!("{}***{}", &id[0..4], &id[id.len() - 4..])
|
||||||
|
} else {
|
||||||
|
id.to_string()
|
||||||
|
};
|
||||||
debug!("获取到用户openid: {}", masked_openid);
|
debug!("获取到用户openid: {}", masked_openid);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ fn build_alipay_form_html(
|
|||||||
subject: &str,
|
subject: &str,
|
||||||
notify_url: &str,
|
notify_url: &str,
|
||||||
return_url: &str,
|
return_url: &str,
|
||||||
is_mobile: bool, // true=手机网站支付, false=电脑网站支付
|
_is_mobile: bool, // 保留参数:待支付宝开通 wap.pay 后启用
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// 注意:当前支付宝产品仅开通了 alipay.trade.page.pay(电脑网站支付)
|
// 注意:当前支付宝产品仅开通了 alipay.trade.page.pay(电脑网站支付)
|
||||||
// 该接口在手机浏览器中也能正常唤起支付宝 APP 或显示移动端页面
|
// 该接口在手机浏览器中也能正常唤起支付宝 APP 或显示移动端页面
|
||||||
@@ -295,8 +295,10 @@ fn extract_token(req: &HttpRequest) -> Option<String> {
|
|||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_jwt_secret() -> String {
|
fn get_jwt_secret() -> Result<String, AppError> {
|
||||||
std::env::var("JWT_SECRET").expect("JWT_SECRET must be set")
|
std::env::var("JWT_SECRET").map_err(|_| {
|
||||||
|
AppError::Internal("JWT_SECRET 环境变量未设置".to_string())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
|
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
|
||||||
@@ -742,7 +744,8 @@ pub async fn payment_page(
|
|||||||
.or_else(|| query.jwt.clone())
|
.or_else(|| query.jwt.clone())
|
||||||
.ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
.ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
||||||
|
|
||||||
let claims = crate::auth::verify_token(&token, &get_jwt_secret())
|
let secret = get_jwt_secret()?;
|
||||||
|
let claims = crate::auth::verify_token(&token, &secret)
|
||||||
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
||||||
|
|
||||||
let pkg = get_package_info(&query.package_)
|
let pkg = get_package_info(&query.package_)
|
||||||
@@ -802,6 +805,7 @@ pub async fn payment_page(
|
|||||||
let return_url = format!("{}/payment/success?order_no={}&jwt={}", base_url, order_no, token);
|
let return_url = format!("{}/payment/success?order_no={}&jwt={}", base_url, order_no, token);
|
||||||
|
|
||||||
let Some(config) = AlipayConfig::from_env() else {
|
let Some(config) = AlipayConfig::from_env() else {
|
||||||
|
check_mock_payment_allowed(&req)?;
|
||||||
let jwt_for_mock = token.clone();
|
let jwt_for_mock = token.clone();
|
||||||
let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock);
|
let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock);
|
||||||
return Ok(HttpResponse::Ok()
|
return Ok(HttpResponse::Ok()
|
||||||
@@ -855,7 +859,8 @@ pub async fn alipay_pay_page(
|
|||||||
) -> Result<HttpResponse, AppError> {
|
) -> Result<HttpResponse, AppError> {
|
||||||
check_payment_maintenance()?;
|
check_payment_maintenance()?;
|
||||||
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
||||||
crate::auth::verify_token(&token, &get_jwt_secret())
|
let secret = get_jwt_secret()?;
|
||||||
|
crate::auth::verify_token(&token, &secret)
|
||||||
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
||||||
|
|
||||||
let pkg = get_package_info(&query.package_type)
|
let pkg = get_package_info(&query.package_type)
|
||||||
@@ -947,13 +952,12 @@ fn parse_alipay_form(body: &[u8]) -> BTreeMap<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 手动 URL 解码(percent-decoding)
|
/// 手动 URL 解码(percent-decoding)
|
||||||
|
/// 注意:`+` 始终解码为空格(form-urlencoded 标准),而非仅在 `%` 转义之后
|
||||||
fn urlencoding(s: &str) -> String {
|
fn urlencoding(s: &str) -> String {
|
||||||
let mut result = String::with_capacity(s.len());
|
let mut result = String::with_capacity(s.len());
|
||||||
let mut chars = s.bytes().peekable();
|
let mut chars = s.bytes().peekable();
|
||||||
let mut had_escape = false;
|
|
||||||
while let Some(b) = chars.next() {
|
while let Some(b) = chars.next() {
|
||||||
if b == b'%' {
|
if b == b'%' {
|
||||||
had_escape = true;
|
|
||||||
let hi = chars.next().and_then(hex_val);
|
let hi = chars.next().and_then(hex_val);
|
||||||
let lo = chars.next().and_then(hex_val);
|
let lo = chars.next().and_then(hex_val);
|
||||||
if let (Some(h), Some(l)) = (hi, lo) {
|
if let (Some(h), Some(l)) = (hi, lo) {
|
||||||
@@ -961,7 +965,7 @@ fn urlencoding(s: &str) -> String {
|
|||||||
} else {
|
} else {
|
||||||
result.push('%');
|
result.push('%');
|
||||||
}
|
}
|
||||||
} else if b == b'+' && had_escape {
|
} else if b == b'+' {
|
||||||
result.push(' ');
|
result.push(' ');
|
||||||
} else {
|
} else {
|
||||||
result.push(b as char);
|
result.push(b as char);
|
||||||
@@ -1570,8 +1574,15 @@ pub async fn payment_login_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 已确认 → 使用已生成的 token
|
// 已确认 → 使用已生成的 token
|
||||||
let token = token.unwrap();
|
let token = token.unwrap_or_default();
|
||||||
let user_id = user_id.unwrap();
|
let Some(user_id) = user_id else {
|
||||||
|
return Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": false, "confirmed": false,
|
||||||
|
"token": None::<String>,
|
||||||
|
"is_active_member": false,
|
||||||
|
"membership_expires_at": None::<String>,
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
let (is_active_member, membership_expires_at): (bool, Option<String>) =
|
let (is_active_member, membership_expires_at): (bool, Option<String>) =
|
||||||
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub async fn get_current_user_profile(
|
|||||||
let user = db::get_user_by_id(pool.get_ref(), user_id).await?;
|
let user = db::get_user_by_id(pool.get_ref(), user_id).await?;
|
||||||
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
|
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
|
||||||
let is_active_member = if is_maintenance {
|
let is_active_member = if is_maintenance {
|
||||||
|
// 维护模式下所有已认证用户视为活跃会员,确保支付故障期间服务可用
|
||||||
|
// 注意:此路由有 JWT 中间件保护,未认证请求已在前置中间件被拦截
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
user.is_member &&
|
user.is_member &&
|
||||||
|
|||||||
16
src/main.rs
16
src/main.rs
@@ -230,7 +230,9 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.connect_timeout(std::time::Duration::from_secs(5))
|
.connect_timeout(std::time::Duration::from_secs(5))
|
||||||
.build()
|
.build()
|
||||||
.expect("创建 HTTP 客户端失败");
|
.unwrap_or_else(|e| {
|
||||||
|
panic!("创建 HTTP 客户端失败: {}", e);
|
||||||
|
});
|
||||||
|
|
||||||
// 启动时清理:过期订单 + 过期登录码
|
// 启动时清理:过期订单 + 过期登录码
|
||||||
match db::cleanup_expired_pending_orders(&pool, None).await {
|
match db::cleanup_expired_pending_orders(&pool, None).await {
|
||||||
@@ -260,13 +262,19 @@ async fn main() -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 会员到期前 7 天提醒
|
// 会员到期前 7 天提醒
|
||||||
let _ = db::check_member_expiry_soon(&pool_clone).await;
|
if let Err(e) = db::check_member_expiry_soon(&pool_clone).await {
|
||||||
|
tracing::error!("检查会员到期失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理超过 24 小时的过期待支付订单
|
// 清理超过 24 小时的过期待支付订单
|
||||||
let _ = db::cleanup_expired_pending_orders(&pool_clone, None).await;
|
if let Err(e) = db::cleanup_expired_pending_orders(&pool_clone, None).await {
|
||||||
|
tracing::error!("清理过期订单失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理过期的 refresh_token
|
// 清理过期的 refresh_token
|
||||||
let _ = db::cleanup_expired_refresh_tokens(&pool_clone).await;
|
if let Err(e) = db::cleanup_expired_refresh_tokens(&pool_clone).await {
|
||||||
|
tracing::error!("清理过期 refresh token 失败: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub struct Claims {
|
|||||||
pub user_id: i32,
|
pub user_id: i32,
|
||||||
// 自定义字段:openid(可选)
|
// 自定义字段:openid(可选)
|
||||||
pub openid: String,
|
pub openid: String,
|
||||||
// 自定义字段:user_ytpe
|
// 自定义字段:user_type
|
||||||
pub user_type: i32,
|
pub user_type: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,24 +51,6 @@ impl LoginResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容旧的 TokenResponse
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
|
||||||
pub struct TokenResponse {
|
|
||||||
pub success: bool,
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl TokenResponse {
|
|
||||||
pub fn new(token: String) -> Self {
|
|
||||||
Self {
|
|
||||||
success: true,
|
|
||||||
token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh Token 请求
|
// Refresh Token 请求
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RefreshTokenRequest {
|
pub struct RefreshTokenRequest {
|
||||||
@@ -347,17 +329,6 @@ pub struct AppState {
|
|||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub wechat_appid: String,
|
pub wechat_appid: String,
|
||||||
pub wechat_secret: String,
|
pub wechat_secret: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub free_user_data_limit: i32,
|
|
||||||
// ===== 支付宝配置 =====
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub alipay_app_id: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub alipay_private_key: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub alipay_alipay_public_key: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub alipay_gateway: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -369,14 +340,6 @@ impl AppState {
|
|||||||
.map_err(|_| "环境变量WECHAT_APPID未设置".to_string())?,
|
.map_err(|_| "环境变量WECHAT_APPID未设置".to_string())?,
|
||||||
wechat_secret: std::env::var("WECHAT_SECRET")
|
wechat_secret: std::env::var("WECHAT_SECRET")
|
||||||
.map_err(|_| "环境变量WECHAT_SECRET未设置".to_string())?,
|
.map_err(|_| "环境变量WECHAT_SECRET未设置".to_string())?,
|
||||||
free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT")
|
|
||||||
.map(|v| v.parse().unwrap_or(20))
|
|
||||||
.unwrap_or(20),
|
|
||||||
// 支付宝配置(可选,未配置时使用模拟支付)
|
|
||||||
alipay_app_id: std::env::var("ALIPAY_APP_ID").ok(),
|
|
||||||
alipay_private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok(),
|
|
||||||
alipay_alipay_public_key: std::env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok(),
|
|
||||||
alipay_gateway: std::env::var("ALIPAY_GATEWAY").ok(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ impl RateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[cfg(test)]
|
||||||
pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String {
|
pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String {
|
||||||
headers
|
headers
|
||||||
.get("X-Forwarded-For")
|
.get("X-Forwarded-For")
|
||||||
|
|||||||
Reference in New Issue
Block a user