feat: gate recharge payment by login device

This commit is contained in:
2026-05-15 08:43:21 +08:00
parent 5b70ec6af7
commit c94f22e26c
12 changed files with 527 additions and 72 deletions

View File

@@ -66,6 +66,15 @@ pub enum BindingStatus {
PendingBindPhone,
}
// JWT 里只保留一份规范化后的设备快照,用于后端按登录设备拦截充值路径。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AccessTokenDeviceInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
}
// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AccessTokenClaimsInput {
@@ -92,6 +101,8 @@ pub struct AccessTokenClaims {
pub binding_status: BindingStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<AccessTokenDeviceInfo>,
pub iat: u64,
pub exp: u64,
}
@@ -1481,11 +1492,21 @@ impl AccessTokenClaims {
input: AccessTokenClaimsInput,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
Self::from_input_with_device(input, None, config, issued_at)
}
pub fn from_input_with_device(
input: AccessTokenClaimsInput,
device: Option<AccessTokenDeviceInfo>,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?;
let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?;
let roles = normalize_roles(input.roles)?;
let display_name = normalize_optional_field(input.display_name);
let device = device.map(|device| device.normalize()).transpose()?;
let issued_at_unix = issued_at.unix_timestamp();
if issued_at_unix < 0 {
@@ -1515,6 +1536,7 @@ impl AccessTokenClaims {
phone_verified: input.phone_verified,
binding_status: input.binding_status,
display_name,
device,
iat: issued_at_unix as u64,
exp: expires_at_unix as u64,
};
@@ -1535,6 +1557,46 @@ impl AccessTokenClaims {
self.ver
}
pub fn client_type(&self) -> Option<&str> {
self.device
.as_ref()
.map(|device| device.client_type.as_str())
}
pub fn client_platform(&self) -> Option<&str> {
self.device
.as_ref()
.map(|device| device.client_platform.as_str())
}
pub fn is_wechat_mini_program_device(&self) -> bool {
matches!(self.client_type(), Some("mini_program"))
}
pub fn is_wechat_h5_device(&self) -> bool {
matches!(self.client_type(), Some("wechat_h5"))
}
pub fn is_wechat_payment_device(&self) -> bool {
self.is_wechat_mini_program_device() || self.is_wechat_h5_device()
}
pub fn is_mobile_device(&self) -> bool {
matches!(self.client_platform(), Some("ios" | "android"))
}
pub fn is_desktop_device(&self) -> bool {
matches!(self.client_platform(), Some("windows" | "macos" | "linux"))
}
pub fn is_mobile_wechat_browser_device(&self) -> bool {
self.is_wechat_h5_device() && self.is_mobile_device()
}
pub fn is_desktop_wechat_browser_device(&self) -> bool {
self.is_wechat_h5_device() && self.is_desktop_device()
}
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
if self.iss.trim() != config.issuer() {
return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致"));
@@ -1543,6 +1605,9 @@ impl AccessTokenClaims {
normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?;
normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?;
normalize_roles(self.roles.clone())?;
if let Some(device) = &self.device {
device.validate()?;
}
if self.exp <= self.iat {
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
@@ -1552,6 +1617,38 @@ impl AccessTokenClaims {
}
}
impl AccessTokenDeviceInfo {
pub fn normalize(self) -> Result<Self, JwtError> {
Ok(Self {
client_type: normalize_required_field(
self.client_type,
"JWT device.client_type 不能为空",
)?,
client_runtime: normalize_required_field(
self.client_runtime,
"JWT device.client_runtime 不能为空",
)?,
client_platform: normalize_required_field(
self.client_platform,
"JWT device.client_platform 不能为空",
)?,
})
}
pub fn validate(&self) -> Result<(), JwtError> {
normalize_required_field(self.client_type.clone(), "JWT device.client_type 不能为空")?;
normalize_required_field(
self.client_runtime.clone(),
"JWT device.client_runtime 不能为空",
)?;
normalize_required_field(
self.client_platform.clone(),
"JWT device.client_platform 不能为空",
)?;
Ok(())
}
}
pub fn sign_access_token(
claims: &AccessTokenClaims,
config: &JwtConfig,
@@ -2129,10 +2226,7 @@ mod tests {
let phone_info = payload.phone_info.expect("phone info should exist");
assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000"));
assert_eq!(
phone_info.pure_phone_number.as_deref(),
Some("13800138000")
);
assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000"));
assert_eq!(phone_info.country_code.as_deref(), Some("86"));
}