支付系统核心:配额限制与用户付费状态管理

- 添加 tokio 依赖用于异步测试
- 启用 User 结构体,添加 is_paid/is_admin/paid_expires_at 字段
- 添加 UpdatePaymentRequest 请求体
- insert_weather_data 集成配额检查逻辑
- 新增 get_user_by_id、count_user_weather_data、update_user_payment_status
- 添加 payment_fields 数据库迁移脚本
This commit is contained in:
2026-03-25 13:17:36 +08:00
parent 63b6d5df8f
commit 484146aba9
5 changed files with 117 additions and 19 deletions

13
Cargo.lock generated
View File

@@ -2079,6 +2079,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"tokio",
]
[[package]]
@@ -2718,9 +2719,21 @@ dependencies = [
"signal-hook-registry",
"slab",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.59.0",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"

View File

@@ -18,3 +18,7 @@ reqwest = { version = "0.12.23", features=["json"]}
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }

View File

@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_paid BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN IF NOT EXISTS paid_expires_at TIMESTAMPTZ DEFAULT NULL;

101
src/db.rs
View File

@@ -1,28 +1,30 @@
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::env;
use std::error::Error;
use chrono::Utc;
// 从 models 模块引入 WeatherData 结构体
use crate::models::{WeatherData, WeatherDataBrief, WeatherListResponse};
use crate::models::{User, WeatherData, WeatherDataBrief, WeatherListResponse};
// 用于插入weather_data的数据
pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData) -> Result<i32, String> {
// 1. 根据 openid 查询 user_id (这部分逻辑不变)
let user_id = match sqlx::query_as::<_, (i32,)>("SELECT id FROM users WHERE openid = $1")
.bind(&weather_data.openid)
.fetch_optional(pool)
.await
{
Ok(Some((id,))) => id,
Ok(None) => {
return Err(format!("未找到openid为 {} 的用户", weather_data.openid));
}
Err(e) => {
return Err(format!("查询用户失败: {}", e));
}
};
pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result<i32, String> {
// 配额检查:非付费用户数据条数限制
let user = get_user_by_id(pool, user_id).await?;
let is_paid_active = user.is_paid && (user.paid_expires_at.is_none() || user.paid_expires_at.unwrap() > Utc::now());
// 2. 准备插入数据的 SQL 语句 (SQL本身不变)
if !is_paid_active {
let current_count = count_user_weather_data(pool, user_id).await?;
let limit: i64 = env::var("FREE_USER_DATA_LIMIT")
.unwrap_or_else(|_| "20".to_string())
.parse()
.unwrap_or(20);
if current_count >= limit {
return Err("数据条数已达上限,请升级为付费用户".to_string());
}
}
// 准备插入数据的 SQL 语句
let insert_query = r#"
INSERT INTO weather_data (
user_id, title, date, hour, min, longitude, latitude, daysincejanfirst,
@@ -242,3 +244,68 @@ pub async fn delete_weather_data(
Err(e) => Err(format!("删除天气数据失败: {}", e)),
}
}
// 根据用户ID获取用户信息
pub async fn get_user_by_id(pool: &PgPool, user_id: i32) -> Result<User, String> {
let query = r#"
SELECT
id, name, openid, phone, type, desc, is_paid, is_admin, paid_expires_at
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(format!("未找到ID为 {} 的用户", user_id)),
Err(e) => return Err(format!("查询用户信息失败: {}", e)),
};
Ok(row)
}
// 统计用户的天气数据条数
pub async fn count_user_weather_data(pool: &PgPool, user_id: i32) -> Result<i64, String> {
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(format!("查询天气数据条数失败: {}", e)),
};
Ok(count)
}
// 更新用户付费状态
pub async fn update_user_payment_status(
pool: &PgPool,
user_id: i32,
is_paid: bool,
paid_expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), String> {
let query = r#"
UPDATE users
SET is_paid = $1, paid_expires_at = $2
WHERE id = $3
"#;
match sqlx::query(query)
.bind(is_paid)
.bind(paid_expires_at)
.bind(user_id)
.execute(pool)
.await
{
Ok(_) => Ok(()),
Err(e) => Err(format!("更新用户付费状态失败: {}", e)),
}
}

View File

@@ -272,8 +272,7 @@ pub struct WeatherData {
pub wind_speed_suitability: String,
}
/*
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: i32,
#[serde(rename = "name")]
@@ -289,5 +288,17 @@ pub struct User {
#[serde(rename = "desc")]
#[sqlx(rename = "desc")]
pub description: Option<String>,
#[sqlx(rename = "is_paid")]
pub is_paid: bool,
#[sqlx(rename = "is_admin")]
pub is_admin: bool,
#[sqlx(rename = "paid_expires_at")]
pub paid_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
// 管理员更新用户付费状态的请求体
#[derive(Debug, Deserialize)]
pub struct UpdatePaymentRequest {
pub is_paid: bool,
pub paid_expires_at: Option<String>, // ISO 8601 格式
}
*/