Files
asd-backend/src/db.rs
milky0217 3cce81321f test: 补充单元测试并修复 urlencoding 解码 bug
- auth.rs 新增 JWT 生成/验证回环、篡改拒绝、refresh token 随机性、临时 token 有效期测试
- payment.rs 新增套餐定义、URL 解码、支付宝表单解析(UTF-8/GBK)、RSA2 签名验签、UA 检测测试
- 修复 urlencoding 两个真实 bug:UTF-8 多字节序列乱码、不完整转义吞字符
- 移除 db.rs 悬空 doc 注释,修复 auth.rs 冗余引用(clippy 0 警告)
- cargo test 从 2 个增至 36 个全过
- 同步 IMPROVEMENTS.md(索引/重复查询/测试覆盖/公告系统状态)
2026-08-14 13:09:55 +08:00

1302 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use sqlx::{PgPool, postgres::PgPoolOptions, FromRow};
use std::env;
use std::error::Error;
use chrono::Utc;
// 从 models 模块引入 WeatherData 结构体
use crate::models::{User, WeatherData, WeatherDataBrief, WeatherListResponse};
use crate::error::AppError;
// 用于插入weather_data的数据
pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result<i32, AppError> {
// 单次查询获取用户会员状态和数据条数(原 N+1get_user_by_id + count_user_weather_data → 合并为 1 次)
#[derive(FromRow)]
struct UserQuota {
is_member: bool,
#[sqlx(rename = "membership_expires_at")]
membership_expires_at: Option<chrono::DateTime<Utc>>,
data_count: i64,
}
let quota = sqlx::query_as::<_, UserQuota>(
r#"
SELECT
u.is_member,
u.membership_expires_at,
(SELECT COUNT(*) FROM weather_data w WHERE w.user_id = $1)::BIGINT AS data_count
FROM users u
WHERE u.id = $1
"#,
)
.bind(user_id)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询用户配额失败: {}", e)))?
.ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?;
let is_active_member = quota.is_member && quota.membership_expires_at.is_none_or(|expires| expires > Utc::now());
// 维护模式:非会员使用更高的临时限额,防止资源滥用
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
if !is_active_member {
let limit: i64 = if is_maintenance {
env::var("MAINTENANCE_MODE_DATA_LIMIT")
.unwrap_or_else(|_| "500".to_string())
.parse()
.unwrap_or(500)
} else {
env::var("FREE_USER_DATA_LIMIT")
.unwrap_or_else(|_| "20".to_string())
.parse()
.unwrap_or(20)
};
if quota.data_count >= limit {
return Err(AppError::Forbidden("数据条数已达上限,请升级为付费用户".to_string()));
}
}
// 准备插入数据的 SQL 语句
let insert_query = r#"
INSERT INTO weather_data (
user_id, title, date, hour, min, longitude, latitude, daysincejanfirst,
theta, solardeclination, sunaltitude, overallcloudiness, lowcloudiness,
cloudindex, solarradiationlevel, hasmeasuredwindspeed, measuredwindspeed,
convertedwindspeed, pointheight, areatype, pointwindspeed,
atmosphericstability, suitabilitydegree, winddirection, averagewinddirection,
winddirectionstandarddeviation, windspeed, averagewindspeed, windspeedsuitability,
winddirectionsuitability, overallsuitability, inspectiontype, assignmentnumber,
calculatedwindspeed, hasspotcheckwindspeed, spotcheckcount, version, is_favorite
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38
) RETURNING id
"#;
// 3. 执行插入操作
let inserted_id = match sqlx::query_as::<_, (i32,)>(insert_query)
.bind(user_id)
.bind(&weather_data.title)
.bind(weather_data.date)
.bind(weather_data.hours)
.bind(weather_data.min)
.bind(&weather_data.longitude)
.bind(&weather_data.latitude)
.bind(weather_data.day_since_jan_first)
.bind(weather_data.theta)
.bind(weather_data.solar_declination)
.bind(weather_data.sun_altitude)
.bind(&weather_data.overall_cloudiness)
.bind(&weather_data.low_cloudiness)
.bind(&weather_data.cloud_index)
.bind(weather_data.solar_radiation_level)
.bind(weather_data.has_measured_wind_speed)
.bind(weather_data.measured_wind_speed) // <-- 修改: 直接绑定 Option<f64>
.bind(weather_data.converted_wind_speed) // <-- 修改: 直接绑定 Option<i32>,并移除 `as i32`
.bind(weather_data.point_height) // <-- 修改: 直接绑定 Option<f64>
.bind(&weather_data.area_type)
.bind(weather_data.point_wind_speed) // <-- 修改: 直接绑定 Option<f64>
.bind(&weather_data.atmospheric_stability)
.bind(&weather_data.suitability_degree)
.bind(
serde_json::to_value(&weather_data.wind_direction)
.map_err(|e| AppError::Internal(format!("JSON 序列化失败: {}", e)))?,
)
.bind(weather_data.average_wind_direction)
.bind(weather_data.wind_direction_standard_deviation)
.bind(
serde_json::to_value(&weather_data.wind_speed)
.map_err(|e| AppError::Internal(format!("JSON 序列化失败: {}", e)))?,
)
.bind(weather_data.average_wind_speed)
.bind(&weather_data.wind_speed_suitability)
.bind(&weather_data.wind_direction_suitability)
.bind(&weather_data.overall_suitability)
.bind(&weather_data.inspection_type)
.bind(&weather_data.assignment_number)
.bind(weather_data.calculated_wind_speed) // 这个本来就是 Option无需修改
.bind(weather_data.has_spot_check_wind_speed)
.bind(weather_data.spot_check_count)
.bind(&weather_data.version)
.bind(weather_data.is_favorite)
.fetch_one(pool)
.await
{
Ok((id,)) => id,
Err(e) => {
return Err(AppError::Database(format!("插入数据失败: {}", e)));
}
};
// 4. 成功,返回插入的 ID
Ok(inserted_id)
}
pub async fn create_pool() -> Result<PgPool, Box<dyn Error>> {
// 获取数据库URL并提供友好错误信息
let database_url =
env::var("DATABASE_URL").map_err(|_| "环境变量DATABASE_URL未设置请在.env文件中配置")?;
// 配置连接池参数
let pool = PgPoolOptions::new()
.max_connections(20) // 根据应用需求调整最大连接数
.acquire_timeout(std::time::Duration::from_secs(30)) // 获取连接的超时时间
.connect(&database_url)
.await
.map_err(|e| format!("数据库连接失败: {}", e))?;
Ok(pool)
}
// 获取天气数据详情
pub async fn get_weather_details(pool: &PgPool, weather_id: i32) -> Result<WeatherData, AppError> {
let query = r#"
SELECT
wd.id, wd.title, wd.date, wd.hour, wd.min,
wd.longitude, wd.latitude,
wd.daysincejanfirst, wd.theta, wd.solardeclination,
wd.sunaltitude, wd.overallcloudiness, wd.lowcloudiness,
wd.cloudindex, wd.solarradiationlevel, wd.hasmeasuredwindspeed,
wd.measuredwindspeed, wd.convertedwindspeed, wd.pointheight,
wd.areatype, wd.pointwindspeed, wd.atmosphericstability,
wd.suitabilitydegree, wd.winddirection, wd.averagewinddirection,
wd.winddirectionstandarddeviation, wd.windspeed, wd.averagewindspeed,
wd.windspeedsuitability, wd.winddirectionsuitability, wd.overallsuitability,
wd.inspectiontype, wd.assignmentnumber, wd.calculatedwindspeed, wd.hasspotcheckwindspeed,
wd.spotcheckcount, wd.is_favorite, u.openid, wd.version
FROM weather_data wd
JOIN users u ON wd.user_id = u.id
WHERE wd.id = $1
"#;
let row = match sqlx::query_as::<_, WeatherData>(query)
.bind(weather_id)
.fetch_optional(pool)
.await
{
Ok(Some(row)) => row,
Ok(None) => return Err(AppError::NotFound(format!("未找到ID为 {} 的天气数据", weather_id))),
Err(e) => return Err(AppError::Database(format!("查询天气数据失败: {}", e))),
};
Ok(row)
}
// get_weather_list函数
pub async fn get_weather_list(
pool: &PgPool,
user_id: i32,
page: i32,
limit: i32,
) -> Result<WeatherListResponse, AppError> {
let offset = (page - 1) * limit;
// 1. 查询符合条件的总条数
let total_query = r#"
SELECT COUNT(*) as total
FROM weather_data
WHERE user_id = $1
"#;
let total = match sqlx::query_as::<_, (i64,)>(total_query)
.bind(user_id)
.fetch_one(pool)
.await
{
Ok((count,)) => count,
Err(e) => return Err(AppError::Database(format!("查询总条数失败: {}", e))),
};
// 2. 查询当前页数据列表
let list_query = r#"
SELECT
wd.id, wd.title, wd.date, wd.hour, wd.min, wd.longitude, wd.latitude, wd.is_favorite
FROM weather_data wd
WHERE wd.user_id = $1
ORDER BY wd.date DESC, wd.hour DESC, wd.min DESC
LIMIT $2 OFFSET $3
"#;
let list = match sqlx::query_as::<_, WeatherDataBrief>(list_query)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
{
Ok(data) => data,
Err(e) => return Err(AppError::Database(format!("查询天气数据列表失败: {}", e))),
};
// 3. 包装结果并返回
Ok(WeatherListResponse { list, total })
}
pub async fn delete_weather_data(
pool: &PgPool,
weather_id: i32,
user_id: i32,
) -> Result<(), AppError> {
let query = r#"
DELETE FROM weather_data
WHERE id = $1 AND user_id = $2
"#;
let result = sqlx::query(query)
.bind(weather_id)
.bind(user_id)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("删除天气数据失败: {}", e)))?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("未找到ID为 {} 的天气数据或无权限删除", weather_id)));
}
Ok(())
}
// 根据用户ID获取用户信息
pub async fn get_user_by_id(pool: &PgPool, user_id: i32) -> Result<User, AppError> {
let query = r#"
SELECT
id, name, openid, phone, type, "desc", is_member, is_admin, membership_expires_at,
avatar_url, nickname
FROM users
WHERE id = $1
"#;
let row = match sqlx::query_as::<_, User>(query)
.bind(user_id)
.fetch_optional(pool)
.await
{
Ok(Some(row)) => row,
Ok(None) => return Err(AppError::NotFound(format!("未找到ID为 {} 的用户", user_id))),
Err(e) => return Err(AppError::Database(format!("查询用户信息失败: {}", e))),
};
Ok(row)
}
// 统计用户的天气数据条数
pub async fn count_user_weather_data(pool: &PgPool, user_id: i32) -> Result<i64, AppError> {
let query = r#"
SELECT COUNT(*) FROM weather_data WHERE user_id = $1
"#;
let count = match sqlx::query_as::<_, (i64,)>(query)
.bind(user_id)
.fetch_one(pool)
.await
{
Ok((count,)) => count,
Err(e) => return Err(AppError::Database(format!("查询天气数据条数失败: {}", e))),
};
Ok(count)
}
// 更新用户付费状态
pub async fn update_user_payment_status(
pool: &PgPool,
user_id: i32,
is_member: bool,
membership_expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), AppError> {
let query = r#"
UPDATE users
SET is_member = $1, membership_expires_at = $2
WHERE id = $3
"#;
match sqlx::query(query)
.bind(is_member)
.bind(membership_expires_at)
.bind(user_id)
.execute(pool)
.await
{
Ok(_) => Ok(()),
Err(e) => Err(AppError::Database(format!("更新用户付费状态失败: {}", e))),
}
}
// 更新用户个人信息
pub async fn update_user_profile(
pool: &PgPool,
user_id: i32,
nickname: &Option<String>,
avatar_url: &Option<String>,
) -> Result<(), AppError> {
let query = r#"
UPDATE users
SET nickname = COALESCE($1, nickname),
avatar_url = COALESCE($2, avatar_url)
WHERE id = $3
"#;
match sqlx::query(query)
.bind(nickname)
.bind(avatar_url)
.bind(user_id)
.execute(pool)
.await
{
Ok(_) => Ok(()),
Err(e) => Err(AppError::Database(format!("更新用户个人信息失败: {}", e))),
}
}
// ===== 支付系统 DB 函数 =====
/// 创建待支付订单
pub async fn get_user_orders(
pool: &PgPool,
user_id: i32,
) -> Result<Vec<crate::models::PaymentOrder>, AppError> {
let rows = sqlx::query_as::<_, crate::models::PaymentOrder>(
r#"
SELECT id, user_id, order_no, package_type, amount, status, paid_at, expires_at, created_at
FROM payment_orders
WHERE user_id = $1
ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
Ok(rows)
}
/// 检查会员到期前 7 天的用户,发送即将到期通知
pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> {
let affected = sqlx::query(
r#"
INSERT INTO notifications (scope, user_id, type, title, content)
SELECT 'user', u.id, 'member_expiry_soon',
'会员即将到期',
CONCAT('您的会员将于 ', TO_CHAR(u.membership_expires_at, 'YYYY-MM-DD'), ' 到期,请及时续费')
FROM users u
WHERE u.is_member = true
AND u.membership_expires_at IS NOT NULL
AND u.membership_expires_at > NOW()
AND u.membership_expires_at <= NOW() + INTERVAL '7 days'
AND NOT EXISTS (
SELECT 1 FROM notifications n
WHERE n.user_id = u.id
AND n.type = 'member_expiry_soon'
AND n.created_at > NOW() - INTERVAL '1 day'
)
"#,
)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("检查会员到期失败: {}", e)))?
.rows_affected();
if affected > 0 {
tracing::info!("已发送 {} 条会员到期提醒", affected);
}
Ok(affected)
}
/// 清理超过 24 小时仍未支付的待处理订单
pub async fn cleanup_expired_pending_orders(pool: &PgPool, user_id: Option<i32>) -> Result<u64, AppError> {
let affected = if let Some(uid) = user_id {
sqlx::query(
r#"
UPDATE payment_orders
SET status = 'cancelled'
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '24 hours'
AND user_id = $1
"#,
)
.bind(uid)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("清理过期订单失败: {}", e)))?
.rows_affected()
} else {
sqlx::query(
r#"
UPDATE payment_orders
SET status = 'cancelled'
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '24 hours'
"#,
)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("清理过期订单失败: {}", e)))?
.rows_affected()
};
if affected > 0 {
tracing::info!("已清理 {} 个过期待支付订单", affected);
}
Ok(affected)
}
pub async fn create_payment_order(
pool: &PgPool,
user_id: i32,
order_no: &str,
package_type: &str,
amount: i32,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), AppError> {
// 创建前先清理该用户的过期 pending 订单
let _ = cleanup_expired_pending_orders(pool, Some(user_id)).await;
let query = r#"
INSERT INTO payment_orders (user_id, order_no, package_type, amount, expires_at)
VALUES ($1, $2, $3, $4, $5)
"#;
sqlx::query(query)
.bind(user_id)
.bind(order_no)
.bind(package_type)
.bind(amount)
.bind(expires_at)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("创建订单失败: {}", e)))?;
Ok(())
}
/// 清理所有已过期的 web 登录码
pub async fn cleanup_expired_login_codes(pool: &PgPool) -> Result<u64, AppError> {
let affected = sqlx::query("DELETE FROM web_login_codes WHERE expires_at < NOW()")
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("清理过期登录码失败: {}", e)))?
.rows_affected();
if affected > 0 {
tracing::info!("已清理 {} 个过期的 web 登录码", affected);
}
Ok(affected)
}
/// 确认订单支付(模拟):更新订单状态 + 激活用户付费
///
/// 返回该订单的 expires_at永久会员为 None
/// 确认订单支付(模拟):更新订单状态 + 激活用户付费(累加有效期)
///
/// 返回计算后的到期时间(永久会员返回 None
/// 插入支付成功通知
async fn insert_payment_notification(pool: &PgPool, user_id: i32, order_no: &str) -> Result<(), AppError> {
// 查出套餐信息
let pkg_info: Option<(String, String)> = sqlx::query_as(
r#"SELECT package_type, status FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let (pkg_type, status) = match pkg_info {
Some(info) => info,
None => return Ok(()),
};
if status != "paid" {
return Ok(());
}
let pkg_label = match pkg_type.as_str() {
"monthly" => "包月会员",
"quarterly" => "季卡会员",
"half_year" => "半年会员",
"yearly" => "包年会员",
"permanent" => "永久会员",
_ => "会员",
};
let title = format!("{}开通成功", pkg_label);
let content = format!("恭喜!您已成功开通{}", pkg_label);
sqlx::query(
r#"INSERT INTO notifications (scope, user_id, type, title, content)
VALUES ('user', $1, 'payment_success', $2, $3)"#,
)
.bind(user_id)
.bind(&title)
.bind(&content)
.execute(pool)
.await
.ok();
Ok(())
}
pub async fn confirm_payment_order(
pool: &PgPool,
order_no: &str,
user_id: i32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, AppError> {
// 检查订单有效
let row = sqlx::query_as::<_, (i32, String)>(
r#"SELECT user_id, status FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let (order_user_id, status) = match row {
Some(r) => r,
None => return Err(AppError::NotFound("订单不存在".to_string())),
};
if order_user_id != user_id {
return Err(AppError::Forbidden("无权操作此订单".to_string()));
}
if status != "pending" {
let hint = if status == "paid" {
"该订单已支付成功,无需重复操作"
} else if status == "cancelled" {
"该订单已被取消,如已扣款请联系客服处理"
} else {
"该订单状态异常,请联系客服"
};
return Err(AppError::BadRequest(hint.to_string()));
}
// 一次性完成:更新订单状态 + 累加计算新的到期时间(纯 SQL
let new_expires: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
r#"
WITH updated_order AS (
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 AND status = 'pending' RETURNING package_type
)
UPDATE users SET
is_member = true,
membership_expires_at =
CASE
WHEN uo.package_type = 'permanent' THEN NULL
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
CASE
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
ELSE INTERVAL '0 days'
END
END
FROM updated_order uo
WHERE users.id = $2
RETURNING users.membership_expires_at
"#,
)
.bind(order_no)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("支付确认失败: {}", e)))?;
// 发送支付成功通知
let _ = insert_payment_notification(pool, user_id, order_no).await;
// 验证会员到期时间是否正常
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
tracing::warn!("会员到期时间验证异常 (订单 {}): {}", order_no, e);
}
Ok(new_expires)
}
/// 确认订单支付(支付宝异步回调用,通过 order_no 查找,不校验 user_id
pub async fn confirm_payment_order_by_orderno(
pool: &PgPool,
order_no: &str,
) -> Result<(), AppError> {
// 检查订单有效
let status: Option<String> = sqlx::query_scalar(
r#"SELECT status FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let status = match status {
Some(s) => s,
None => return Err(AppError::NotFound("订单不存在".to_string())),
};
// 支付宝回调已通过 RSA2 验证。如果订单被用户误取消,重新激活。
if status == "cancelled" {
tracing::warn!("订单 {} 已被取消,但支付宝确认已收款,重新激活并处理支付", order_no);
} else if status != "pending" {
return Ok(());
}
// 查出订单关联的用户(用于审计日志)
let user_id: Option<i32> = sqlx::query_scalar(
r#"SELECT user_id FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.ok()
.flatten();
// 一次性完成:更新订单状态 + 累加计算新的到期时间
// 加上 AND status = 'pending' 防止竞态覆盖已取消/退款的订单
let result = sqlx::query(
r#"
WITH updated_order AS (
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 AND status IN ('pending', 'cancelled') RETURNING package_type, user_id
)
UPDATE users SET
is_member = true,
membership_expires_at =
CASE
WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
CASE
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
ELSE INTERVAL '0 days'
END
END
FROM updated_order uo
WHERE users.id = uo.user_id
"#,
)
.bind(order_no)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("支付确认失败: {}", e)))?;
// 0 行 update 说明订单已被取消/退款/已确认(竞态保护生效)
if result.rows_affected() == 0 {
tracing::warn!("支付确认: 订单 {} 状态已变更,跳过处理(可能是竞态或重复回调)", order_no);
return Ok(());
}
// 审计日志
if let Some(uid) = user_id {
if let Err(e) = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await {
tracing::warn!("插入支付审计日志失败: {}", e);
}
// 发送支付成功通知
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 {
tracing::warn!("会员到期时间验证异常 (订单 {}): {}", order_no, e);
}
}
Ok(())
}
/// 退款处理:标记订单为 refunded + 撤销用户会员(如果无其他有效订单)
pub async fn refund_payment_order(
pool: &PgPool,
order_no: &str,
) -> Result<(), AppError> {
// 先查出订单关联的用户
let order_info: Option<(i32, String, Option<chrono::DateTime<chrono::Utc>>)> =
sqlx::query_as(
r#"SELECT user_id, status, paid_at FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let (user_id, status, _paid_at) = match order_info {
Some(info) => info,
None => return Err(AppError::NotFound("订单不存在".to_string())),
};
if status != "paid" {
tracing::warn!("订单 {} 状态为 {},跳过退款处理", order_no, status);
return Ok(());
}
// 标记订单为 refunded
sqlx::query("UPDATE payment_orders SET status = 'refunded' WHERE order_no = $1")
.bind(order_no)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("退款标记失败: {}", e)))?;
// 检查该用户是否有其他有效的已支付订单
let other_active: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM payment_orders
WHERE user_id = $1
AND status = 'paid'
AND order_no != $2
AND (expires_at IS NULL OR expires_at > NOW())
"#,
)
.bind(user_id)
.bind(order_no)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("查询其他有效订单失败: {}", e)))?;
// 如果没有其他有效订单,撤销会员状态
if other_active.0 == 0 {
sqlx::query(
"UPDATE users SET is_member = false, membership_expires_at = NULL WHERE id = $1",
)
.bind(user_id)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("撤销会员失败: {}", e)))?;
tracing::info!("用户 {} 的会员因订单 {} 退款已被撤销", user_id, order_no);
} else {
tracing::info!(
"用户 {} 有其他有效订单({}笔),跳过会员撤销",
user_id,
other_active.0
);
}
// 审计日志
let detail = if other_active.0 == 0 { "退款,会员已撤销" } else { "退款,有其他有效订单,保留会员" };
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await {
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(())
}
/// 获取用户配额信息
///
/// 返回 (已用条数, 是否付费活跃, 到期时间)
pub async fn get_user_quota(
pool: &PgPool,
user_id: i32,
) -> Result<(i64, bool, Option<chrono::DateTime<chrono::Utc>>, bool), AppError> {
let user = get_user_by_id(pool, user_id).await?;
let is_active_member = user.is_member
&& user.membership_expires_at.is_none_or(|expires| expires > chrono::Utc::now());
let used = count_user_weather_data(pool, user_id).await?;
Ok((used, is_active_member, user.membership_expires_at, user.is_member))
}
// ===== 收藏功能 DB 函数 =====
/// 获取收藏列表(分页)
pub async fn get_favorites_list(
pool: &PgPool,
user_id: i32,
page: i32,
limit: i32,
) -> Result<WeatherListResponse, AppError> {
let offset = (page - 1) * limit;
let total_query = r#"
SELECT COUNT(*) as total
FROM weather_data
WHERE user_id = $1 AND is_favorite = true
"#;
let total = sqlx::query_as::<_, (i64,)>(total_query)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("查询收藏总数失败: {}", e)))?
.0;
let list_query = r#"
SELECT
id, title, date, hour, min, longitude, latitude, is_favorite
FROM weather_data
WHERE user_id = $1 AND is_favorite = true
ORDER BY date DESC, hour DESC, min DESC
LIMIT $2 OFFSET $3
"#;
let list = sqlx::query_as::<_, WeatherDataBrief>(list_query)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| AppError::Database(format!("查询收藏列表失败: {}", e)))?;
Ok(WeatherListResponse { list, total })
}
/// 设置天气数据的收藏状态
pub async fn set_weather_favorite(
pool: &PgPool,
weather_id: i32,
user_id: i32,
is_favorite: bool,
) -> Result<(), AppError> {
let query = r#"
UPDATE weather_data
SET is_favorite = $1
WHERE id = $2 AND user_id = $3
"#;
let result = sqlx::query(query)
.bind(is_favorite)
.bind(weather_id)
.bind(user_id)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("更新收藏状态失败: {}", e)))?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("未找到ID为 {} 的天气数据或无权限修改", weather_id)));
}
Ok(())
}
// ===== Refresh Token DB 函数 =====
#[derive(Debug, FromRow)]
pub struct RefreshToken {
#[allow(dead_code)] // sqlx deserialization only; not read directly
pub id: i32,
pub user_id: i32,
#[allow(dead_code)] // sqlx deserialization only
pub token: String,
#[allow(dead_code)] // sqlx deserialization only
pub expires_at: chrono::DateTime<chrono::Utc>,
#[allow(dead_code)] // sqlx deserialization only
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn create_refresh_token(
pool: &PgPool,
user_id: i32,
token: &str,
expires_at: chrono::DateTime<chrono::Utc>,
) -> Result<(), AppError> {
let query = r#"
INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES ($1, $2, $3)
"#;
sqlx::query(query)
.bind(user_id)
.bind(token)
.bind(expires_at)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("创建 refresh_token 失败: {}", e)))?;
Ok(())
}
pub async fn verify_refresh_token(
pool: &PgPool,
token: &str,
) -> Result<RefreshToken, AppError> {
let query = r#"
SELECT id, user_id, token, expires_at, created_at
FROM refresh_tokens
WHERE token = $1 AND expires_at > NOW()
"#;
sqlx::query_as::<_, RefreshToken>(query)
.bind(token)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("验证 refresh_token 失败: {}", e)))?
.ok_or_else(|| AppError::Unauthorized("Refresh token 无效或已过期".to_string()))
}
#[allow(dead_code)]
pub async fn delete_refresh_token(pool: &PgPool, user_id: i32) -> Result<(), AppError> {
let query = r#"DELETE FROM refresh_tokens WHERE user_id = $1"#;
sqlx::query(query)
.bind(user_id)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("删除 refresh_token 失败: {}", e)))?;
Ok(())
}
pub async fn delete_specific_refresh_token(pool: &PgPool, token: &str) -> Result<(), AppError> {
let query = r#"DELETE FROM refresh_tokens WHERE token = $1"#;
sqlx::query(query)
.bind(token)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("删除 refresh_token 失败: {}", e)))?;
Ok(())
}
pub async fn cleanup_expired_refresh_tokens(pool: &PgPool) -> Result<u64, AppError> {
let query = r#"DELETE FROM refresh_tokens WHERE expires_at <= NOW()"#;
let result = sqlx::query(query)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("清理过期 refresh_token 失败: {}", e)))?;
Ok(result.rows_affected())
}
// ===== 支付审计日志 =====
/// 写入支付审计日志
pub async fn insert_payment_audit_log(
pool: &PgPool,
order_no: &str,
user_id: i32,
action: &str,
operator_id: Option<i32>,
detail: Option<&str>,
) -> Result<(), AppError> {
// 自动建表(幂等)
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS payment_audit_log (
id SERIAL PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
user_id INTEGER NOT NULL,
action VARCHAR(32) NOT NULL,
operator_id INTEGER,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"#,
)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("创建支付审计表失败: {}", e)))?;
sqlx::query(
r#"
INSERT INTO payment_audit_log (order_no, user_id, action, operator_id, detail)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(order_no)
.bind(user_id)
.bind(action)
.bind(operator_id)
.bind(detail)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("写入支付审计日志失败: {}", e)))?;
Ok(())
}
/// 管理员强制确认待支付订单(带审计日志)
pub async fn admin_force_confirm_order(
pool: &PgPool,
order_no: &str,
admin_user_id: i32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, AppError> {
// 查出订单关联的用户
let order = sqlx::query_as::<_, (i32, String)>(
r#"SELECT user_id, status FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let (user_id, status) = match order {
Some(o) => o,
None => return Err(AppError::NotFound("订单不存在".to_string())),
};
// 非管理员不能操作
// (调用方已校验)
let new_expires = if status == "pending" {
// 直接执行确认 SQL跳过 user_id 校验,由管理员操作)
let result = sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
r#"
WITH updated_order AS (
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 AND status = 'pending' RETURNING package_type
)
UPDATE users SET
is_member = true,
membership_expires_at =
CASE
WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
CASE
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
ELSE INTERVAL '0 days'
END
END
FROM updated_order uo
WHERE users.id = $2
RETURNING users.membership_expires_at
"#,
)
.bind(order_no)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?;
// 写审计日志
if let Err(e) = insert_payment_audit_log(
pool,
order_no,
user_id,
"admin_confirm",
Some(admin_user_id),
Some("管理员强制确认支付"),
)
.await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
// 验证会员到期时间是否正常
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
tracing::warn!("会员到期时间验证异常 (订单 {}): {}", order_no, e);
}
result
} else {
// 订单已处理,只写日志
if let Err(e) = insert_payment_audit_log(
pool,
order_no,
user_id,
"admin_confirm",
Some(admin_user_id),
Some(&format!("订单状态为 {},跳过确认", status)),
)
.await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
// 返回当前到期时间
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
r#"SELECT membership_expires_at FROM users WHERE id = $1"#,
)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("查询用户到期时间失败: {}", e)))?
};
Ok(new_expires)
}
/// 用户主动取消待支付订单
pub async fn cancel_payment_order(
pool: &PgPool,
order_no: &str,
user_id: i32,
) -> Result<(), AppError> {
// 检查订单有效且属于该用户
let row = sqlx::query_as::<_, (i32, String)>(
r#"SELECT user_id, status FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let (order_user_id, status) = match row {
Some(r) => r,
None => return Err(AppError::NotFound("订单不存在".to_string())),
};
if order_user_id != user_id {
return Err(AppError::Forbidden("无权操作此订单".to_string()));
}
if status != "pending" {
return Err(AppError::BadRequest(format!("订单状态为 {},无法取消", status)));
}
// 防止用户误取消:支付宝支付确认通常在 5-30 秒内到达
// 2 分钟内的订单不允许取消,避免用户付款后误触取消按钮
let order_age: f64 = sqlx::query_scalar(
r#"SELECT EXTRACT(EPOCH FROM (NOW() - created_at)) FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单创建时间失败: {}", e)))?;
if order_age < 120.0 {
return Err(AppError::BadRequest(
"订单刚刚创建,支付可能仍在处理中,请 2 分钟后再试".to_string(),
));
}
sqlx::query("UPDATE payment_orders SET status = 'cancelled' WHERE order_no = $1")
.bind(order_no)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?;
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id),
Some("用户主动取消待支付订单")).await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(())
}
/// 管理员手动退款(标记订单为 refunded + 重新计算会员)
pub async fn admin_refund_order(
pool: &PgPool,
order_no: &str,
admin_user_id: i32,
) -> Result<(), AppError> {
// 查出订单信息
let order = sqlx::query_as::<_, (i32, String)>(
r#"SELECT user_id, status FROM payment_orders WHERE order_no = $1"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
let (user_id, status) = match order {
Some(o) => o,
None => return Err(AppError::NotFound("订单不存在".to_string())),
};
if status != "paid" && status != "pending" {
return Err(AppError::BadRequest(format!("订单状态为 {},无法退款", status)));
}
// 标记为 refunded
sqlx::query("UPDATE payment_orders SET status = 'refunded' WHERE order_no = $1")
.bind(order_no)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("退款标记失败: {}", e)))?;
// 如果订单是已支付状态,重新计算会员
if status == "paid" {
// 检查该用户是否有其他有效的已支付订单
let other_active: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM payment_orders
WHERE user_id = $1
AND status = 'paid'
AND order_no != $2
AND (expires_at IS NULL OR expires_at > NOW())
"#,
)
.bind(user_id)
.bind(order_no)
.fetch_one(pool)
.await
.map_err(|e| AppError::Database(format!("查询其他有效订单失败: {}", e)))?;
if other_active.0 == 0 {
sqlx::query(
"UPDATE users SET is_member = false, membership_expires_at = NULL WHERE id = $1",
)
.bind(user_id)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("撤销会员失败: {}", e)))?;
tracing::info!("管理员退款: 用户 {} 的会员因订单 {} 退款已被撤销", user_id, order_no);
} else {
tracing::info!(
"管理员退款: 用户 {} 有其他有效订单({}笔),保留会员",
user_id,
other_active.0
);
}
}
let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" };
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded",
Some(admin_user_id), Some(detail)).await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(())
}
/// 支付确认后验证会员到期时间是否正常
/// 用于捕获 ELSE INTERVAL '0 days' 的静默错误
async fn verify_membership_after_payment(
pool: &PgPool,
order_no: &str,
) -> Result<(), AppError> {
#[derive(Debug, sqlx::FromRow)]
struct PaymentResult {
package_type: String,
paid_at: chrono::DateTime<chrono::Utc>,
membership_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
let row = sqlx::query_as::<_, PaymentResult>(
r#"
SELECT po.package_type, po.paid_at, u.membership_expires_at
FROM payment_orders po
JOIN users u ON u.id = po.user_id
WHERE po.order_no = $1
"#,
)
.bind(order_no)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("验证查询失败: {}", e)))?;
let r = match row {
Some(r) => r,
None => return Err(AppError::NotFound("验证查询:订单不存在".to_string())),
};
if r.package_type == "permanent" {
return Ok(()); // 永久会员不累计
}
let expires = match r.membership_expires_at {
Some(e) => e,
None => {
let msg = format!("支付验证失败:{} 非永久套餐但会员到期为 NULL", r.package_type);
tracing::error!("{}", msg);
return Err(AppError::Internal(msg));
}
};
let actual_days = (expires - r.paid_at).num_days();
// 预期至少 25 天(最短月卡 30 天,允许 5 天误差)
if actual_days < 25 {
let msg = format!(
"支付验证失败:套餐 {} 仅增加 {} 天会员时长(预期 >= 25 天),可能触发了 ELSE 分支",
r.package_type, actual_days
);
tracing::error!("{}", msg);
return Err(AppError::Internal(msg));
}
tracing::info!("支付验证通过:{} 增加 {} 天", r.package_type, actual_days);
Ok(())
}