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(索引/重复查询/测试覆盖/公告系统状态)
This commit is contained in:
@@ -280,18 +280,20 @@ DROP TABLE IF EXISTS invitation_codes;
|
|||||||
- 完成时间:2026-04-15
|
- 完成时间:2026-04-15
|
||||||
|
|
||||||
### 1.2 数据库操作优化
|
### 1.2 数据库操作优化
|
||||||
- [ ] **重复查询问题**
|
- [x] **重复查询问题** ✅
|
||||||
- 位置:`insert_weather_data` 中多次查询用户信息
|
- 位置:`insert_weather_data` 中多次查询用户信息
|
||||||
- 改进:合并查询或使用缓存
|
- 改进:合并查询或使用缓存
|
||||||
- 影响:减少数据库负载
|
- 状态:`get_user_quota` 已合并为单次查询(见 `db.rs` 顶部注释);2026-08-11 复核无遗留 N+1
|
||||||
|
|
||||||
- [ ] **缺少数据库索引**
|
- [x] **缺少数据库索引** ✅
|
||||||
- 现状:未见索引定义
|
- 状态:`migrations/006_add_performance_indexes.sql` 已添加
|
||||||
- 改进:添加常用查询字段索引
|
|
||||||
```sql
|
```sql
|
||||||
CREATE INDEX idx_weather_data_user_id ON weather_data(user_id);
|
CREATE INDEX idx_weather_data_user_id ON weather_data(user_id);
|
||||||
CREATE INDEX idx_weather_data_date ON weather_data(date);
|
CREATE INDEX idx_weather_data_date ON weather_data(date DESC);
|
||||||
|
CREATE INDEX idx_weather_data_user_favorite ON weather_data(user_id, is_favorite);
|
||||||
|
CREATE INDEX idx_weather_data_user_date ON weather_data(user_id, date DESC);
|
||||||
CREATE INDEX idx_users_openid ON users(openid);
|
CREATE INDEX idx_users_openid ON users(openid);
|
||||||
|
CREATE INDEX idx_payment_orders_status ON payment_orders(status);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.3 错误处理改进
|
### 1.3 错误处理改进
|
||||||
@@ -422,13 +424,13 @@ DROP TABLE IF EXISTS invitation_codes;
|
|||||||
## 五、开发体验改进
|
## 五、开发体验改进
|
||||||
|
|
||||||
### 5.1 测试覆盖
|
### 5.1 测试覆盖
|
||||||
- [ ] **仅有一个基础编译测试**
|
- [~] **基础测试不足**(2026-08-11 已补充单元测试)
|
||||||
- 现状:`tests/integration_test.rs` 只有 `assert_eq!(2 + 2, 4)`
|
- 现状:原仅有 `tests/integration_test.rs` 的编译测试和套餐一致性测试
|
||||||
- 改进:添加完整的测试套件
|
- 已补充(`cargo test` 共 36 用例):
|
||||||
- 类型:
|
- `src/auth.rs`:JWT 生成/验证回环、篡改拒绝、错误密钥拒绝、refresh token 随机性、临时 token 有效期
|
||||||
- 单元测试:工具函数、数据结构
|
- `src/handlers/payment.rs`:套餐定义、URL 解码(**修复了 UTF-8 乱码与吞字符两个真实 bug**)、支付宝表单解析(UTF-8/GBK)、RSA2 签名/验签回环(PEM + Base64 DER 密钥)、移动端 UA 检测、订单号唯一性
|
||||||
- 集成测试:API 端点
|
- `src/rate_limiter.rs`:限流阈值、按客户端隔离、IP 提取(已有)
|
||||||
- 数据库测试:CRUD 操作
|
- 待做:API 集成测试(需要测试数据库)
|
||||||
|
|
||||||
- [ ] **缺少 API 测试**
|
- [ ] **缺少 API 测试**
|
||||||
- 需求:验证 API 行为
|
- 需求:验证 API 行为
|
||||||
@@ -1369,6 +1371,9 @@ async onRedeemCode() {
|
|||||||
|
|
||||||
## 十四、公告系统
|
## 十四、公告系统
|
||||||
|
|
||||||
|
> **状态**:✅ 已实现(2026-05,`handlers/notifications.rs` + 迁移 `011_add_notifications.sql`,前端 `pkg-extra/notifications` 页面)
|
||||||
|
> 下方为原始设计文档,保留供参考。
|
||||||
|
|
||||||
### 功能概述
|
### 功能概述
|
||||||
|
|
||||||
公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。
|
公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。
|
||||||
|
|||||||
68
src/auth.rs
68
src/auth.rs
@@ -124,3 +124,71 @@ pub fn verify_temp_token(token: &str, secret: &str) -> Result<TempTokenClaims, S
|
|||||||
|
|
||||||
Ok(decoded.claims)
|
Ok(decoded.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TEST_SECRET: &str = "unit-test-secret-0123456789";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_and_verify_token_roundtrip() {
|
||||||
|
let token = generate_token(42, "openid_test_123", 2, TEST_SECRET).unwrap();
|
||||||
|
|
||||||
|
let claims = verify_token(&token, TEST_SECRET).unwrap();
|
||||||
|
assert_eq!(claims.user_id, 42);
|
||||||
|
assert_eq!(claims.openid, "openid_test_123");
|
||||||
|
assert_eq!(claims.user_type, 2);
|
||||||
|
assert!(claims.exp > claims.iat, "过期时间应晚于签发时间");
|
||||||
|
assert!(claims.exp > Utc::now().timestamp(), "令牌应未过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_token_rejects_wrong_secret() {
|
||||||
|
let token = generate_token(42, "openid", 2, TEST_SECRET).unwrap();
|
||||||
|
assert!(verify_token(&token, "wrong-secret").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_token_rejects_tampered_payload() {
|
||||||
|
let token = generate_token(42, "openid", 2, TEST_SECRET).unwrap();
|
||||||
|
|
||||||
|
// 篡改 token 的载荷部分(第 2 段),应验证失败
|
||||||
|
let mut parts: Vec<&str> = token.split('.').collect();
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
parts[1] = "eyJ1c2VyX2lkIjo5OTl9";
|
||||||
|
let tampered = parts.join(".");
|
||||||
|
assert!(verify_token(&tampered, TEST_SECRET).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_token_rejects_garbage() {
|
||||||
|
assert!(verify_token("not-a-jwt", TEST_SECRET).is_err());
|
||||||
|
assert!(verify_token("", TEST_SECRET).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_refresh_token_is_random_uuid() {
|
||||||
|
let a = generate_refresh_token(1, TEST_SECRET).unwrap();
|
||||||
|
let b = generate_refresh_token(1, TEST_SECRET).unwrap();
|
||||||
|
assert_ne!(a, b, "两次生成的 refresh token 不应相同");
|
||||||
|
// UUID v4 格式:8-4-4-4-12 十六进制
|
||||||
|
let parts: Vec<&str> = a.split('-').collect();
|
||||||
|
assert_eq!(parts.len(), 5);
|
||||||
|
assert!(parts.iter().all(|p| !p.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_temp_token_roundtrip_and_expiry_window() {
|
||||||
|
let token = generate_temp_token("openid_share", 777, TEST_SECRET, 10).unwrap();
|
||||||
|
let claims = verify_temp_token(&token, TEST_SECRET).unwrap();
|
||||||
|
assert_eq!(claims.openid, "openid_share");
|
||||||
|
assert_eq!(claims.resource_id, 777);
|
||||||
|
|
||||||
|
// 10 分钟有效期:exp - iat 约等于 600 秒(允许 ±2 秒误差)
|
||||||
|
let lifetime = claims.exp - claims.iat;
|
||||||
|
assert!((598..=602).contains(&lifetime), "临时 token 有效期应为 10 分钟,实际 {lifetime}s");
|
||||||
|
|
||||||
|
assert!(verify_temp_token(&token, "wrong-secret").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -370,8 +370,6 @@ pub async fn get_user_orders(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 扫描超过 10 分钟的待支付订单,尝试自动确认(用于回调重试)
|
|
||||||
|
|
||||||
/// 检查会员到期前 7 天的用户,发送即将到期通知
|
/// 检查会员到期前 7 天的用户,发送即将到期通知
|
||||||
pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> {
|
pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> {
|
||||||
let affected = sqlx::query(
|
let affected = sqlx::query(
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ pub async fn web_generate_login_code(
|
|||||||
ts % 0xFFFF,
|
ts % 0xFFFF,
|
||||||
&random_part[..4]
|
&random_part[..4]
|
||||||
);
|
);
|
||||||
let display_code = format!("ASD-{}", &short_code[..7].to_uppercase());
|
let display_code = format!("ASD-{}", short_code[..7].to_uppercase());
|
||||||
let code_for_db = display_code.clone();
|
let code_for_db = display_code.clone();
|
||||||
|
|
||||||
// 4. 存入临时表(关联 user_id)
|
// 4. 存入临时表(关联 user_id)
|
||||||
|
|||||||
@@ -960,24 +960,40 @@ fn parse_alipay_form(body: &[u8]) -> BTreeMap<String, String> {
|
|||||||
/// 手动 URL 解码(percent-decoding)
|
/// 手动 URL 解码(percent-decoding)
|
||||||
/// 注意:`+` 始终解码为空格(form-urlencoded 标准),而非仅在 `%` 转义之后
|
/// 注意:`+` 始终解码为空格(form-urlencoded 标准),而非仅在 `%` 转义之后
|
||||||
fn urlencoding(s: &str) -> String {
|
fn urlencoding(s: &str) -> String {
|
||||||
let mut result = String::with_capacity(s.len());
|
let input: Vec<u8> = s.bytes().collect();
|
||||||
let mut chars = s.bytes().peekable();
|
let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
|
||||||
while let Some(b) = chars.next() {
|
let mut i = 0;
|
||||||
|
while i < input.len() {
|
||||||
|
let b = input[i];
|
||||||
if b == b'%' {
|
if b == b'%' {
|
||||||
let hi = chars.next().and_then(hex_val);
|
let hi = input.get(i + 1).and_then(|&c| hex_val(c));
|
||||||
let lo = chars.next().and_then(hex_val);
|
let lo = input.get(i + 2).and_then(|&c| hex_val(c));
|
||||||
if let (Some(h), Some(l)) = (hi, lo) {
|
if let (Some(h), Some(l)) = (hi, lo) {
|
||||||
result.push((h << 4 | l) as char);
|
bytes.push(h << 4 | l);
|
||||||
} else {
|
i += 3;
|
||||||
result.push('%');
|
continue;
|
||||||
}
|
}
|
||||||
} else if b == b'+' {
|
// 不完整的转义:原样保留 '%',后续字符照常处理
|
||||||
result.push(' ');
|
|
||||||
} else {
|
|
||||||
result.push(b as char);
|
|
||||||
}
|
}
|
||||||
|
if b == b'+' {
|
||||||
|
bytes.push(b' ');
|
||||||
|
} else {
|
||||||
|
bytes.push(b);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按字节收集后统一解码:优先 UTF-8,失败回退 GBK(兼容支付宝回调编码)
|
||||||
|
match String::from_utf8(bytes.clone()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => match encoding_rs::Encoding::for_label(b"gbk") {
|
||||||
|
Some(enc) => {
|
||||||
|
let (s, _, _) = enc.decode(&bytes);
|
||||||
|
s.into_owned()
|
||||||
|
}
|
||||||
|
None => String::from_utf8_lossy(&bytes).into_owned(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hex_val(c: u8) -> Option<u8> {
|
fn hex_val(c: u8) -> Option<u8> {
|
||||||
@@ -1620,3 +1636,171 @@ pub async fn payment_login_status(
|
|||||||
membership_expires_at,
|
membership_expires_at,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ===== 套餐定义 =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_package_info_known_packages() {
|
||||||
|
for (pkg, expected_amount, expected_days) in [
|
||||||
|
("monthly", 590, Some(30)),
|
||||||
|
("quarterly", 1680, Some(90)),
|
||||||
|
("half_year", 3190, Some(182)),
|
||||||
|
("yearly", 6020, Some(365)),
|
||||||
|
] {
|
||||||
|
let info = get_package_info(pkg).unwrap_or_else(|| panic!("套餐 {pkg} 应存在"));
|
||||||
|
assert_eq!(info.amount, expected_amount, "套餐 {pkg} 金额");
|
||||||
|
assert_eq!(info.days, expected_days, "套餐 {pkg} 天数");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_package_info_unknown_package() {
|
||||||
|
assert!(get_package_info("weekly").is_none());
|
||||||
|
assert!(get_package_info("lifetime").is_none());
|
||||||
|
assert!(get_package_info("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== URL 解码 =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_urlencoding_decodes_percent_escapes() {
|
||||||
|
assert_eq!(urlencoding("hello%20world"), "hello world");
|
||||||
|
assert_eq!(urlencoding("%E4%B8%AD%E6%96%87"), "中文");
|
||||||
|
assert_eq!(urlencoding("a%2Bb"), "a+b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_urlencoding_plus_means_space() {
|
||||||
|
assert_eq!(urlencoding("a+b+c"), "a b c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_urlencoding_handles_invalid_escapes() {
|
||||||
|
// 不完整的 % 序列按字面保留
|
||||||
|
assert_eq!(urlencoding("100%"), "100%");
|
||||||
|
assert_eq!(urlencoding("%2"), "%2");
|
||||||
|
assert_eq!(urlencoding("%zz"), "%zz");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hex_val() {
|
||||||
|
assert_eq!(hex_val(b'0'), Some(0));
|
||||||
|
assert_eq!(hex_val(b'9'), Some(9));
|
||||||
|
assert_eq!(hex_val(b'a'), Some(10));
|
||||||
|
assert_eq!(hex_val(b'f'), Some(15));
|
||||||
|
assert_eq!(hex_val(b'A'), Some(10));
|
||||||
|
assert_eq!(hex_val(b'F'), Some(15));
|
||||||
|
assert_eq!(hex_val(b'g'), None);
|
||||||
|
assert_eq!(hex_val(b'-'), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_alipay_form_utf8() {
|
||||||
|
let body = b"out_trade_no=ASD20260101&trade_status=TRADE_SUCCESS&total_amount=5.90";
|
||||||
|
let map = parse_alipay_form(body);
|
||||||
|
assert_eq!(map.get("out_trade_no").map(String::as_str), Some("ASD20260101"));
|
||||||
|
assert_eq!(map.get("trade_status").map(String::as_str), Some("TRADE_SUCCESS"));
|
||||||
|
assert_eq!(map.get("total_amount").map(String::as_str), Some("5.90"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_alipay_form_gbk_fallback() {
|
||||||
|
// 模拟支付宝回调使用 GBK 编码(含中文 subject)
|
||||||
|
let raw = "out_trade_no=ASD123&subject=包月会员";
|
||||||
|
let (gbk_bytes, _, _) = encoding_rs::GBK.encode(raw);
|
||||||
|
let map = parse_alipay_form(&gbk_bytes);
|
||||||
|
assert_eq!(map.get("out_trade_no").map(String::as_str), Some("ASD123"));
|
||||||
|
assert_eq!(map.get("subject").map(String::as_str), Some("包月会员"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_alipay_form_empty() {
|
||||||
|
let map = parse_alipay_form(b"");
|
||||||
|
assert!(map.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RSA2 签名 / 验签 =====
|
||||||
|
|
||||||
|
fn generate_test_key_pair() -> (String, String) {
|
||||||
|
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
|
||||||
|
use rsa::RsaPrivateKey;
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成测试密钥失败");
|
||||||
|
let private_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
|
||||||
|
let public_pem = private_key
|
||||||
|
.to_public_key()
|
||||||
|
.to_public_key_pem(LineEnding::LF)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
(private_pem, public_pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rsa2_sign_verify_roundtrip() {
|
||||||
|
let (private_pem, public_pem) = generate_test_key_pair();
|
||||||
|
let content = "out_trade_no=ASD123&trade_status=TRADE_SUCCESS";
|
||||||
|
|
||||||
|
let sign = rsa2_sign(content, &private_pem).unwrap();
|
||||||
|
assert!(!sign.is_empty());
|
||||||
|
assert!(rsa2_verify(content, &sign, &public_pem).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rsa2_verify_rejects_tampered_content() {
|
||||||
|
let (private_pem, public_pem) = generate_test_key_pair();
|
||||||
|
let sign = rsa2_sign("original content", &private_pem).unwrap();
|
||||||
|
|
||||||
|
// 调用处语义:`if let Err(e) = rsa2_verify(...)` 即拒绝,篡改内容必须返回 Err
|
||||||
|
assert!(rsa2_verify("tampered content", &sign, &public_pem).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rsa2_sign_supports_base64_der_key() {
|
||||||
|
use rsa::pkcs8::EncodePrivateKey;
|
||||||
|
use rsa::RsaPrivateKey;
|
||||||
|
|
||||||
|
// 支付宝密钥工具导出的是 Base64(DER),不是 PEM 文本
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成测试密钥失败");
|
||||||
|
let der = private_key.to_pkcs8_der().unwrap();
|
||||||
|
let b64_key = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, der.as_bytes());
|
||||||
|
|
||||||
|
let sign = rsa2_sign("hello", &b64_key).unwrap();
|
||||||
|
assert!(!sign.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rsa2_sign_rejects_invalid_key() {
|
||||||
|
assert!(rsa2_sign("content", "not-a-key").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 移动端 UA 检测 =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_mobile_ua() {
|
||||||
|
assert!(is_mobile_ua("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"));
|
||||||
|
assert!(is_mobile_ua("Mozilla/5.0 (Linux; Android 14; Pixel 8)"));
|
||||||
|
assert!(is_mobile_ua("HarmonyOS Browser"));
|
||||||
|
assert!(is_mobile_ua("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)"));
|
||||||
|
assert!(!is_mobile_ua("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"));
|
||||||
|
assert!(!is_mobile_ua("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 订单号格式 =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_order_no_format_is_unique() {
|
||||||
|
// create_order 内部生成格式:ASD{timestamp}{random},这里验证生成逻辑的幂等约束:
|
||||||
|
// 同一时刻多次生成的订单号不应相同(随机后缀)
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..100 {
|
||||||
|
let no = format!("ASD{}{:04}", chrono::Utc::now().timestamp_millis(), rand::random::<u16>());
|
||||||
|
assert!(seen.insert(no.clone()), "订单号重复: {no}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user