feat: 实现 Mock 支付功能,修复支付激活会员问题

- 当未配置 ALIPAY_* 环境变量时,自动启用 Mock 支付模式
- Mock 支付页面点击按钮后调用 /api/payment/mock-confirm 激活会员
- 更新 AGENTS.md 文档说明 Mock 支付配置和使用方法
This commit is contained in:
2026-04-28 10:17:26 +08:00
parent ef246db31c
commit 2046638721
4 changed files with 144 additions and 6 deletions

View File

@@ -490,6 +490,24 @@ curl http://127.0.0.1:8080/api/mock-login
Mock 登录支持 `user_id` 参数指定已有用户,或自动创建新用户。环境变量 `MOCK_LOGIN_ENABLED=false` 时返回 403。
### Mock 支付(沙箱测试)
`.env` 中未配置 `ALIPAY_*` 环境变量时,自动启用 Mock 支付模式:
**流程**
```
用户访问 /payment/page → 显示 Mock 支付页面 → 点击"确认模拟支付" → 调用 /api/payment/mock-confirm → 激活会员
```
**特点**
- 无需真实支付宝配置
- Mock 页面显示订单号和套餐信息
- 点击后通过 `POST /api/payment/mock-confirm` 激活会员
- 有效期按套餐天数累加计算
**启用真实支付**
`.env` 中配置 `ALIPAY_APP_ID``ALIPAY_PRIVATE_KEY``ALIPAY_ALIPAY_PUBLIC_KEY``ALIPAY_GATEWAY`,重启服务后自动禁用 Mock 模式。
### 支付模式
| 模式 | 来源 | 处理方式 |

8
Cargo.lock generated
View File

@@ -405,9 +405,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.35"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -689,9 +689,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.0"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"

View File

@@ -254,3 +254,31 @@ mod tests {
assert_eq!(urlencoding("中文"), "%E4%B8%AD%E6%96%87");
}
}
#[cfg(test)]
mod signature_tests {
use super::*;
#[test]
fn test_openssl_signature_consistency() {
// This test verifies our signing matches OpenSSL
// Key from .env (same key as Python test)
let private_key_pem = std::env::var("TEST_ALIPAY_PRIVATE_KEY")
.unwrap_or_else(|_| {
// Default test key - the one we've been using
"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2lBZ0VBQW9JQ0FRREFRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQovZ2dFQQBFBX".to_string()
});
let content = "test content for signature comparison";
// Sign with our function
let result = rsa2_sign(content, &private_key_pem);
assert!(result.is_ok(), "Signing failed: {:?}", result.err());
let signature = result.unwrap();
println!("Our signature: {}", &signature[..signature.len().min(50)]);
// To verify this matches OpenSSL, you would need to run:
// echo -n "test content..." | openssl dgst -sha256 -sign key.pem | base64
}
}

View File

@@ -610,9 +610,55 @@ pub async fn payment_page(
let return_url = format!("{}/payment/success?order_no={}", base_url, order_no);
let Some(config) = AlipayConfig::from_env() else {
let jwt_for_mock = token.clone();
let mock_html = format!(r#"<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>模拟支付</title></head>
<body style="font-family:-apple-system;padding:40px;text-align:center">
<h2 style="color:#52c41a">模拟支付环境</h2>
<p style="color:#666">当前为沙箱模拟环境,无需真实支付</p>
<p style="color:#999;font-size:14px">订单号: {}</p>
<p style="color:#999;font-size:14px">套餐: {}</p>
<button id="confirmBtn" onclick="confirmMockPay()" style="padding:12px 32px;font-size:16px;background:#1677ff;color:#fff;border:none;border-radius:4px;cursor:pointer">确认模拟支付</button>
<p id="result"></p>
<p><a href="/payment" style="color:#1677ff">返回重试</a></p>
<script>
function confirmMockPay() {{
var btn = document.getElementById('confirmBtn');
btn.disabled = true;
btn.textContent = '处理中...';
fetch('/api/payment/mock-confirm', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'Authorization': 'Bearer {}'
}},
body: JSON.stringify({{order_id: '{}'}})
}})
.then(r => r.json())
.then(data => {{
if (data.success) {{
document.getElementById('result').innerHTML = '<span style="color:#52c41a">支付成功!</span>';
setTimeout(() => window.location.href = '/payment?mock=1', 1500);
}} else {{
document.getElementById('result').innerHTML = '<span style="color:#f5222d">失败: ' + (data.error || '未知错误') + '</span>';
btn.disabled = false;
btn.textContent = '重试';
}}
}})
.catch(e => {{
document.getElementById('result').innerHTML = '<span style="color:#f5222d">网络错误</span>';
btn.disabled = false;
btn.textContent = '重试';
}});
}}
</script>
</body>
</html>
"#, order_no, pkg.display_name, jwt_for_mock, order_no);
return Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format_error_html("支付配置不完整,请联系管理员", &order_no)));
.body(mock_html));
};
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
@@ -665,9 +711,55 @@ pub async fn alipay_pay_page(
let return_url = format!("{}/payment/success?order_no={}", base_url, query.order_no);
let Some(config) = AlipayConfig::from_env() else {
let jwt_for_mock = token.clone();
let mock_html = format!(r#"<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>模拟支付</title></head>
<body style="font-family:-apple-system;padding:40px;text-align:center">
<h2 style="color:#52c41a">模拟支付环境</h2>
<p style="color:#666">当前为沙箱模拟环境,无需真实支付</p>
<p style="color:#999;font-size:14px">订单号: {}</p>
<p style="color:#999;font-size:14px">套餐: {}</p>
<button id="confirmBtn" onclick="confirmMockPay()" style="padding:12px 32px;font-size:16px;background:#1677ff;color:#fff;border:none;border-radius:4px;cursor:pointer">确认模拟支付</button>
<p id="result"></p>
<p><a href="/payment" style="color:#1677ff">返回重试</a></p>
<script>
function confirmMockPay() {{
var btn = document.getElementById('confirmBtn');
btn.disabled = true;
btn.textContent = '处理中...';
fetch('/api/payment/mock-confirm', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'Authorization': 'Bearer {}'
}},
body: JSON.stringify({{order_id: '{}'}})
}})
.then(r => r.json())
.then(data => {{
if (data.success) {{
document.getElementById('result').innerHTML = '<span style="color:#52c41a">支付成功!</span>';
setTimeout(() => window.location.href = '/payment?mock=1', 1500);
}} else {{
document.getElementById('result').innerHTML = '<span style="color:#f5222d">失败: ' + (data.error || '未知错误') + '</span>';
btn.disabled = false;
btn.textContent = '重试';
}}
}})
.catch(e => {{
document.getElementById('result').innerHTML = '<span style="color:#f5222d">网络错误</span>';
btn.disabled = false;
btn.textContent = '重试';
}});
}}
</script>
</body>
</html>
"#, query.order_no, pkg.display_name, jwt_for_mock, query.order_no);
return Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format_error_html("支付配置不完整,请联系管理员", &query.order_no)));
.body(mock_html));
};
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);