Merge branch 'hermes/wechat'

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md
#	docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md
#	server-rs/crates/module-runtime/src/errors.rs
#	src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
2026-05-15 11:32:51 +08:00
23 changed files with 2325 additions and 107 deletions

View File

@@ -17,7 +17,7 @@
本阶段已经完成 JWT 基础能力首版落地:
1. 新增 `JwtConfig`,统一管理 `issuer``secret` 与 access token TTL。
2. 新增 `AccessTokenClaimsInput``AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。
2. 新增 `AccessTokenClaimsInput``AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name/device` 映射到 Rust 结构。
3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。
4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。
5. 新增 `RefreshCookieConfig``RefreshCookieSameSite``read_refresh_session_token(...)`,统一 refresh cookie 读取口径。
@@ -36,13 +36,14 @@
1. `JwtConfig::new(...)`
2. `AccessTokenClaims::from_input(...)`
3. `sign_access_token(...)`
4. `verify_access_token(...)`
5. `RefreshCookieConfig::new(...)`
6. `read_refresh_session_token(...)`
7. `AuthProvider`
8. `BindingStatus`
9. `RefreshCookieSameSite`
3. `AccessTokenClaims::from_input_with_device(...)`
4. `sign_access_token(...)`
5. `verify_access_token(...)`
6. `RefreshCookieConfig::new(...)`
7. `read_refresh_session_token(...)`
8. `AuthProvider`
9. `BindingStatus`
10. `RefreshCookieSameSite`
## 4. 配置口径
@@ -67,7 +68,7 @@
1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。
2. `sub` 必须是稳定 `user_id``sid` 必须是会话 ID不能退化为一次 token 的随机 ID。
3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。
3. 不允许把手机号、openid、refresh token hash、风控状态、完整设备对象、IP、UA 等敏感或高频变化字段塞进 JWT`device` 只允许保存支付拦截需要的最小设备快照
4. 鉴权状态最终由 `module-auth``crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。
5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。

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,