From b7f4408992acd01a6aa5b5e4b1cfdf241bcd93f5 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 20:20:01 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(wechat):=20M5=20=E5=BE=AE=E4=BF=A1=20J?= =?UTF-8?q?S-SDK=20=E6=89=AB=E7=A0=81=E7=AD=BE=E5=88=B0=E2=80=94=E2=80=94j?= =?UTF-8?q?ssdk-signature=20=E6=8E=A5=E5=8F=A3=E4=B8=8E=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E6=89=AB=E4=B8=80=E6=89=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增 wechat 模块:access_token / jsapi_ticket 内存缓存(tokio RwLock,提前 300s 刷新)与 plain SHA1 签名;GET /api/wechat/jssdk-signature?url= 供前端 wx.config,url 自动去除 # 及其后部分。凭证经 WECHAT_APP_ID / WECHAT_APP_SECRET 注入,未配置返回 40307 而非 panic。 前端新增 MicroMessenger UA 检测与 jweixin 动态注入(幂等),房间页底栏在微信内 新增「微信扫一扫」主按钮,扫码结果与相机扫码同路径(本地预校验 → share_qr 推送全房间);非微信环境保持相机扫码。 --- .env.example | 5 + backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/src/auth/routes.rs | 14 +- backend/src/config.rs | 28 +++ backend/src/courses/routes.rs | 2 + backend/src/error.rs | 8 + backend/src/main.rs | 2 + backend/src/signin/routes.rs | 2 + backend/src/wechat/client.rs | 296 ++++++++++++++++++++++++++++++++ backend/src/wechat/mod.rs | 7 + backend/src/wechat/routes.rs | 270 +++++++++++++++++++++++++++++ backend/src/ws/routes.rs | 2 + docs/CHANGELOG.md | 4 + frontend/src/api/wechat.ts | 14 ++ frontend/src/lib/wechat.test.ts | 157 +++++++++++++++++ frontend/src/lib/wechat.ts | 104 +++++++++++ frontend/src/pages/Room.tsx | 45 ++++- 18 files changed, 959 insertions(+), 3 deletions(-) create mode 100644 backend/src/wechat/client.rs create mode 100644 backend/src/wechat/mod.rs create mode 100644 backend/src/wechat/routes.rs create mode 100644 frontend/src/api/wechat.ts create mode 100644 frontend/src/lib/wechat.test.ts create mode 100644 frontend/src/lib/wechat.ts diff --git a/.env.example b/.env.example index 9b8850a..8a4efc9 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ YK_ALLOWED_HOSTS=www.yuketang.cn,pro.yuketang.cn,changjiang.yuketang.cn,huanghe. # 复用雨课堂的 CaptchaAppId(实测不限域名) YK_CAPTCHA_APP_ID=2091064951 +# --- 微信公众号(M5 微信内扫码签到;不填则微信内自动降级为相机扫码)--- +# 需已认证服务号/订阅号的 JS 安全域名配置,且与部署域名一致 +WECHAT_APP_ID= +WECHAT_APP_SECRET= + # --- 日志 --- # tracing EnvFilter,如 info / debug / rain_course_backend=debug,tower_http=info LOG_DIR=logs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 07592b6..3128086 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1034,6 +1034,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1", "sha2", "thiserror", "tokio", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index abc0479..11e3bf4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -26,6 +26,7 @@ anyhow = "1" axum-extra = { version = "0.10", features = ["cookie"] } hmac = "0.12" sha2 = "0.10" +sha1 = "0.10" hex = "0.4" [dev-dependencies] diff --git a/backend/src/auth/routes.rs b/backend/src/auth/routes.rs index 78645cf..178347e 100644 --- a/backend/src/auth/routes.rs +++ b/backend/src/auth/routes.rs @@ -19,6 +19,7 @@ use super::token; use super::yk_client::{self, YkClient}; use crate::config::Config; use crate::error::AppError; +use crate::wechat::client::WechatClient; pub const SESSION_COOKIE: &str = "sid"; @@ -26,6 +27,8 @@ pub const SESSION_COOKIE: &str = "sid"; pub struct AppState { pub config: Config, pub yk: YkClient, + /// 微信 JS-SDK 客户端(M5):access_token / jsapi_ticket 内存缓存 + pub wechat: WechatClient, /// user_id → 雨课堂 Cookie 头(内存态,重启即失) pub sessions: RwLock>, /// WS 房间 Hub(docs/DESIGN.md §4,内存态) @@ -33,12 +36,17 @@ pub struct AppState { } impl AppState { - /// 统一构造(Hub 使用配置中的 limits) + /// 统一构造(Hub 使用配置中的 limits;微信凭证来自配置,未配置则相关接口报 40307) pub fn new(config: Config, yk: YkClient) -> Self { let hub = Arc::new(crate::ws::hub::Hub::new(config.limits.clone())); + let wechat = WechatClient::new( + config.wechat_app_id.clone(), + config.wechat_app_secret.clone(), + ); Self { config, yk, + wechat, sessions: RwLock::new(HashMap::new()), hub, } @@ -245,6 +253,8 @@ mod tests { yk_allowed_hosts: vec!["www.yuketang.cn".into()], limits: Default::default(), log_dir: "logs".into(), + wechat_app_id: None, + wechat_app_secret: None, }, YkClient::new(yk_base), ))) @@ -427,6 +437,8 @@ mod tests { yk_allowed_hosts: vec![], limits: Default::default(), log_dir: "logs".into(), + wechat_app_id: None, + wechat_app_secret: None, }, YkClient::new(&server.uri()), )); diff --git a/backend/src/config.rs b/backend/src/config.rs index db927d9..52e7f3d 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -8,6 +8,8 @@ //! - YK_BASE_URL 雨课堂基地址 //! - YK_ALLOWED_HOSTS 雨课堂域名白名单(逗号分隔) //! - RUST_LOG 日志级别(tracing EnvFilter) +//! - WECHAT_APP_ID 微信公众号 AppID(M5 微信内扫码;不填则微信内扫码不可用) +//! - WECHAT_APP_SECRET 微信公众号 AppSecret(同上,不入库) //! - WS_* 房间资源上限(见 [`Limits`],防 OOM,全部可调) use std::time::Duration; @@ -25,6 +27,10 @@ pub struct Config { pub limits: Limits, /// 日志文件目录(按天滚动) pub log_dir: String, + /// 微信公众号 AppID(未配置 = 微信内扫码不可用,前端降级相机) + pub wechat_app_id: Option, + /// 微信公众号 AppSecret(未配置同上;不入库) + pub wechat_app_secret: Option, } /// WS 房间资源上限(docs/SPEC.md §3.3.3,全部硬性要求,可经环境变量调整) @@ -157,6 +163,8 @@ impl Config { })), limits: Limits::from_lookup(&lookup), log_dir: lookup("LOG_DIR").unwrap_or_else(|| "logs".into()), + wechat_app_id: optional_env(&lookup, "WECHAT_APP_ID"), + wechat_app_secret: optional_env(&lookup, "WECHAT_APP_SECRET"), } } @@ -172,6 +180,13 @@ impl Config { } } +/// 可选凭证读取:去空白,空串视作未配置 +fn optional_env Option>(lookup: &F, key: &str) -> Option { + lookup(key) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + /// 解析逗号分隔的域名列表:去空白、去空项、统一小写 pub fn parse_hosts(raw: &str) -> Vec { raw.split(',') @@ -201,6 +216,8 @@ mod tests { "huanghe.yuketang.cn" ] ); + assert_eq!(c.wechat_app_id, None); + assert_eq!(c.wechat_app_secret, None); } #[test] @@ -212,6 +229,8 @@ mod tests { ("YK_CAPTCHA_APP_ID", "9999"), ("YK_BASE_URL", "https://pro.yuketang.cn"), ("YK_ALLOWED_HOSTS", "Pro.Yuketang.cn, other.cn"), + ("WECHAT_APP_ID", " wx12345 "), + ("WECHAT_APP_SECRET", "s3cr3t"), ]); assert_eq!(c.server_secret, "abc"); assert_eq!(c.port, 8080); @@ -219,6 +238,15 @@ mod tests { assert_eq!(c.captcha_app_id, "9999"); assert_eq!(c.yk_base_url, "https://pro.yuketang.cn"); assert_eq!(c.yk_allowed_hosts, vec!["pro.yuketang.cn", "other.cn"]); + assert_eq!(c.wechat_app_id.as_deref(), Some("wx12345")); + assert_eq!(c.wechat_app_secret.as_deref(), Some("s3cr3t")); + } + + #[test] + fn blank_wechat_credentials_treated_as_unset() { + let c = Config::from_pairs(&[("WECHAT_APP_ID", " "), ("WECHAT_APP_SECRET", "")]); + assert_eq!(c.wechat_app_id, None); + assert_eq!(c.wechat_app_secret, None); } #[test] diff --git a/backend/src/courses/routes.rs b/backend/src/courses/routes.rs index 621273f..112301f 100644 --- a/backend/src/courses/routes.rs +++ b/backend/src/courses/routes.rs @@ -95,6 +95,8 @@ mod tests { yk_allowed_hosts: vec!["www.yuketang.cn".into()], limits: Default::default(), log_dir: "logs".into(), + wechat_app_id: None, + wechat_app_secret: None, }, YkClient::new(yk_base), )); diff --git a/backend/src/error.rs b/backend/src/error.rs index 87889e6..de9e409 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -43,6 +43,12 @@ pub enum AppError { /// 房间入参非法(字段超长等) #[error("{0}")] BadRoomInput(&'static str), + /// 请求参数非法(缺字段等) + #[error("{0}")] + BadInput(&'static str), + /// 微信 JS-SDK 未配置(缺少 WECHAT_APP_ID / WECHAT_APP_SECRET) + #[error("微信扫码未配置,请联系管理员")] + WechatNotConfigured, /// 其他内部错误 #[error("内部错误")] Internal(#[from] anyhow::Error), @@ -62,6 +68,8 @@ impl AppError { AppError::RoomNotFound => error_code::ROOM_NOT_FOUND, AppError::NotRoomOwner => error_code::NOT_ROOM_OWNER, AppError::BadRoomInput(_) => error_code::BAD_REQUEST, + AppError::BadInput(_) => error_code::BAD_REQUEST, + AppError::WechatNotConfigured => 40307, AppError::Internal(_) => 50000, } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 5f2cff4..451355b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,6 +8,7 @@ mod config; mod courses; mod error; mod signin; +mod wechat; mod ws; use std::sync::Arc; @@ -45,6 +46,7 @@ fn build_app(cfg: Arc) -> Router { auth::routes::router(state.clone()) .merge(signin::routes::router(state.clone())) .merge(courses::routes::router(state.clone())) + .merge(wechat::routes::router(state.clone())) .merge(ws::routes::router(state)) .merge(health) } diff --git a/backend/src/signin/routes.rs b/backend/src/signin/routes.rs index ed3c4ed..4d4b0b9 100644 --- a/backend/src/signin/routes.rs +++ b/backend/src/signin/routes.rs @@ -98,6 +98,8 @@ mod tests { yk_allowed_hosts: vec!["www.yuketang.cn".into()], limits: Default::default(), log_dir: "logs".into(), + wechat_app_id: None, + wechat_app_secret: None, }, YkClient::new(yk_base), )); diff --git a/backend/src/wechat/client.rs b/backend/src/wechat/client.rs new file mode 100644 index 0000000..eec67f8 --- /dev/null +++ b/backend/src/wechat/client.rs @@ -0,0 +1,296 @@ +//! 微信公众平台接口客户端:access_token / jsapi_ticket 内存缓存 + JS-SDK 签名 +//! +//! 接口(官方文档): +//! - `GET /cgi-bin/token?grant_type=client_credential&appid=&secret=` +//! - `GET /cgi-bin/ticket/getticket?access_token=&type=jsapi` +//! +//! 签名串为 **plain SHA1**(非 HMAC): +//! `jsapi_ticket=..&noncestr=..×tamp=..&url=..`(url 需去掉 `#` 及其后部分) + +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use serde_json::Value; +use sha1::{Digest, Sha1}; +use tokio::sync::RwLock; + +use crate::error::AppError; + +/// 微信开放接口基地址(测试可注入) +const DEFAULT_API_BASE: &str = "https://api.weixin.qq.com"; +/// 提前刷新余量(秒):临界过期会让签名被微信拒绝 +const REFRESH_MARGIN_SECS: u64 = 300; +/// 微信侧凭证默认有效期(秒) +const DEFAULT_EXPIRES_IN: u64 = 7200; + +/// 带过期时间的凭证缓存项 +#[derive(Debug, Clone)] +struct Cached { + value: String, + expires_at: u64, +} + +impl Cached { + fn new(value: String, expires_in: u64) -> Self { + Self { + value, + expires_at: now_unix() + expires_in, + } + } + + /// 距过期仍有余量方可复用 + fn usable(&self, now: u64) -> bool { + now + REFRESH_MARGIN_SECS < self.expires_at + } +} + +/// 前端 `wx.config` 所需签名参数(camelCase 直出) +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JssdkSignature { + pub app_id: String, + pub timestamp: u64, + pub nonce_str: String, + pub signature: String, +} + +pub struct WechatClient { + app_id: Option, + app_secret: Option, + base: String, + http: reqwest::Client, + access_token: RwLock>, + jsapi_ticket: RwLock>, +} + +impl WechatClient { + /// 生产构造:使用微信开放接口默认基地址 + pub fn new(app_id: Option, app_secret: Option) -> Self { + Self::with_base(app_id, app_secret, DEFAULT_API_BASE) + } + + /// 可注入基地址(测试用 wiremock) + pub fn with_base(app_id: Option, app_secret: Option, base: &str) -> Self { + Self { + app_id, + app_secret, + base: base.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + access_token: RwLock::new(None), + jsapi_ticket: RwLock::new(None), + } + } + + /// 生成 JS-SDK 签名;`url` 为当前页面完整 URL(`#` 之后部分会被去除) + pub async fn jssdk_signature(&self, url: &str) -> Result { + let app_id = self.app_id.clone().ok_or(AppError::WechatNotConfigured)?; + let ticket = self.jsapi_ticket().await?; + let timestamp = now_unix(); + let nonce_str = random_nonce(); + let plain = format!( + "jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={}", + strip_fragment(url) + ); + Ok(JssdkSignature { + app_id, + timestamp, + nonce_str, + signature: sha1_hex(&plain), + }) + } + + /// access_token:命中未过期缓存直接返回,否则刷新 + pub async fn access_token(&self) -> Result { + let cached = self.access_token.read().await.clone(); + if let Some(c) = cached + && c.usable(now_unix()) + { + return Ok(c.value); + } + let fresh = self.fetch_access_token().await?; + let value = fresh.value.clone(); + *self.access_token.write().await = Some(fresh); + Ok(value) + } + + /// jsapi_ticket:命中未过期缓存直接返回,否则刷新 + pub async fn jsapi_ticket(&self) -> Result { + let cached = self.jsapi_ticket.read().await.clone(); + if let Some(c) = cached + && c.usable(now_unix()) + { + return Ok(c.value); + } + let fresh = self.fetch_jsapi_ticket().await?; + let value = fresh.value.clone(); + *self.jsapi_ticket.write().await = Some(fresh); + Ok(value) + } + + async fn fetch_access_token(&self) -> Result { + let (app_id, app_secret) = self.credentials()?; + let resp = self + .http + .get(format!("{}/cgi-bin/token", self.base)) + .query(&[ + ("grant_type", "client_credential"), + ("appid", app_id), + ("secret", app_secret), + ]) + .send() + .await + .map_err(internal)?; + let body: Value = resp.json().await.map_err(internal)?; + reject_wechat_error(&body, "access_token")?; + let token = body + .get("access_token") + .and_then(Value::as_str) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("微信响应缺少 access_token")))?; + Ok(Cached::new(token.to_string(), expires_in_of(&body))) + } + + async fn fetch_jsapi_ticket(&self) -> Result { + let token = self.access_token().await?; + let resp = self + .http + .get(format!("{}/cgi-bin/ticket/getticket", self.base)) + .query(&[("access_token", token.as_str()), ("type", "jsapi")]) + .send() + .await + .map_err(internal)?; + let body: Value = resp.json().await.map_err(internal)?; + reject_wechat_error(&body, "jsapi_ticket")?; + let ticket = body + .get("ticket") + .and_then(Value::as_str) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("微信响应缺少 ticket")))?; + Ok(Cached::new(ticket.to_string(), expires_in_of(&body))) + } + + fn credentials(&self) -> Result<(&str, &str), AppError> { + match (self.app_id.as_deref(), self.app_secret.as_deref()) { + (Some(id), Some(secret)) => Ok((id, secret)), + _ => Err(AppError::WechatNotConfigured), + } + } +} + +/// 微信应答的 `expires_in`(缺省按官方默认 7200s) +fn expires_in_of(body: &Value) -> u64 { + body.get("expires_in") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_EXPIRES_IN) +} + +/// 微信以 `errcode == 0` 表示成功;非 0 映射为上游业务错误(保留原始 errcode) +fn reject_wechat_error(body: &Value, stage: &str) -> Result<(), AppError> { + if let Some(code) = body.get("errcode").and_then(Value::as_i64) + && code != 0 + { + let msg = body + .get("errmsg") + .and_then(Value::as_str) + .unwrap_or("微信接口错误"); + return Err(AppError::Upstream { + upstream_code: code, + message: format!("微信 {stage} 获取失败:{msg}"), + }); + } + Ok(()) +} + +/// 去掉 URL 的 fragment(微信签名要求包含 query、不含 `#` 及其后部分) +fn strip_fragment(url: &str) -> &str { + match url.split_once('#') { + Some((before, _)) => before, + None => url, + } +} + +/// JS-SDK 签名算法:plain SHA1(非 HMAC) +fn sha1_hex(plain: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(plain.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// nonceStr:无需密码学强度,进程随机种子 + 纳秒时间足以保证唯一 +fn random_nonce() -> String { + use std::hash::{BuildHasher, Hasher}; + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(now_unix_nanos()); + format!("{:016x}", h.finish()) +} + +fn internal(e: reqwest::Error) -> AppError { + AppError::Internal(e.into()) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn now_unix_nanos() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos() as u64 ^ d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_fragment_removes_hash_and_after() { + assert_eq!( + strip_fragment("https://a.cn/r/12?x=1#/foo"), + "https://a.cn/r/12?x=1" + ); + assert_eq!(strip_fragment("https://a.cn/r/12"), "https://a.cn/r/12"); + } + + #[test] + fn sha1_matches_known_vector() { + // 官方文档示例串的 SHA1("abc" 的标准向量) + assert_eq!(sha1_hex("abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + } + + #[test] + fn cache_usable_only_outside_refresh_margin() { + let now = 1_000_000; + let fresh = Cached { + value: "t".into(), + expires_at: now + 7200, + }; + assert!(fresh.usable(now)); + let edge = Cached { + value: "t".into(), + expires_at: now + REFRESH_MARGIN_SECS, + }; + assert!(!edge.usable(now)); + } + + #[test] + fn nonce_is_hex_and_changes_in_time() { + let a = random_nonce(); + let b = random_nonce(); + assert_eq!(a.len(), 16); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(b.len(), 0); + } + + #[tokio::test] + async fn signature_without_credentials_is_not_configured() { + let client = WechatClient::new(None, Some("s".into())); + let err = client + .jssdk_signature("https://a.cn/r/12") + .await + .unwrap_err(); + assert!(matches!(err, AppError::WechatNotConfigured)); + assert_eq!(err.code(), 40307); + } +} diff --git a/backend/src/wechat/mod.rs b/backend/src/wechat/mod.rs new file mode 100644 index 0000000..8b255cc --- /dev/null +++ b/backend/src/wechat/mod.rs @@ -0,0 +1,7 @@ +//! 微信 JS-SDK(M5):微信内扫码签到 +//! +//! 公众号凭证经环境变量注入(`WECHAT_APP_ID` / `WECHAT_APP_SECRET`), +//! 未配置时不 panic:签名接口返回明确业务错误,前端自动降级为相机扫码。 + +pub mod client; +pub mod routes; diff --git a/backend/src/wechat/routes.rs b/backend/src/wechat/routes.rs new file mode 100644 index 0000000..ef790ea --- /dev/null +++ b/backend/src/wechat/routes.rs @@ -0,0 +1,270 @@ +//! 微信 JS-SDK 签名路由:`GET /api/wechat/jssdk-signature?url=`(M5) +//! +//! 微信内扫码前置:前端拿到签名后 `wx.config`,再 `wx.scanQRCode`。 +//! 需本站会话;未配置公众号凭证时返回 40307(前端自动降级为相机扫码)。 + +use std::sync::Arc; + +use axum::extract::{Query, State}; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; + +use crate::auth::routes::{AppState, extract_sid}; +use crate::auth::token; +use crate::error::AppError; + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/api/wechat/jssdk-signature", get(jssdk_signature)) + .with_state(state) +} + +#[derive(Deserialize)] +pub struct SignatureQuery { + pub url: String, +} + +async fn jssdk_signature( + State(st): State>, + headers: HeaderMap, + Query(q): Query, +) -> Result { + // 本站会话(微信内同为本站页面,cookie 随同源请求携带) + let sid = extract_sid(&headers).ok_or(AppError::Unauthorized)?; + token::verify(&sid, now_unix(), &st.config.server_secret) + .map_err(|_| AppError::Unauthorized)?; + + let url = q.url.trim(); + if url.is_empty() { + return Err(AppError::BadInput("缺少 url 参数")); + } + + let signature = st.wechat.jssdk_signature(url).await?; + Ok(Json(json!({ "code": 0, "msg": "ok", "data": signature })).into_response()) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::extract::Request; + use axum::http::header; + use sha1::{Digest, Sha1}; + use tower::ServiceExt; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use crate::auth::yk_client::YkClient; + use crate::config::Config; + use crate::wechat::client::WechatClient; + + const SECRET: &str = "test-secret"; + + fn app_with(base: &str, configured: bool) -> Router { + let cfg = if configured { + Config::from_pairs(&[ + ("SERVER_SECRET", SECRET), + ("WECHAT_APP_ID", "wxapp"), + ("WECHAT_APP_SECRET", "wxsec"), + ]) + } else { + Config::from_pairs(&[("SERVER_SECRET", SECRET)]) + }; + let mut st = AppState::new(cfg, YkClient::new("https://www.yuketang.cn")); + st.wechat = WechatClient::with_base( + st.config.wechat_app_id.clone(), + st.config.wechat_app_secret.clone(), + base, + ); + router(Arc::new(st)) + } + + fn sid_cookie() -> String { + format!("sid={}", token::issue(42, now_unix() + 3_600, SECRET)) + } + + async fn get(app: Router, uri: &str, cookie: Option<&str>) -> Response { + let mut b = Request::builder().uri(uri); + if let Some(c) = cookie { + b = b.header(header::COOKIE, c); + } + app.oneshot(b.body(Body::empty()).unwrap()).await.unwrap() + } + + async fn body_json(resp: Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + fn sha1_hex_of(plain: &str) -> String { + let mut h = Sha1::new(); + h.update(plain.as_bytes()); + hex::encode(h.finalize()) + } + + async fn mount_wechat(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/cgi-bin/token")) + .and(query_param("grant_type", "client_credential")) + .and(query_param("appid", "wxapp")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"access_token": "AT", "expires_in": 7200})), + ) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/cgi-bin/ticket/getticket")) + .and(query_param("type", "jsapi")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errcode": 0, "errmsg": "ok", "ticket": "TICKET", "expires_in": 7200 + }))) + .mount(server) + .await; + } + + #[tokio::test] + async fn without_site_cookie_is_unauthorized() { + let server = MockServer::start().await; + let resp = get( + app_with(&server.uri(), true), + "/api/wechat/jssdk-signature?url=https%3A%2F%2Fa.cn%2Fr%2F12", + None, + ) + .await; + assert_eq!(body_json(resp).await["code"], 40101); + } + + #[tokio::test] + async fn missing_url_is_bad_request() { + let server = MockServer::start().await; + let resp = get( + app_with(&server.uri(), true), + "/api/wechat/jssdk-signature?url=", + Some(&sid_cookie()), + ) + .await; + assert_eq!(body_json(resp).await["code"], 40306); + } + + #[tokio::test] + async fn unconfigured_credentials_return_40307() { + let server = MockServer::start().await; + let resp = get( + app_with(&server.uri(), false), + "/api/wechat/jssdk-signature?url=https%3A%2F%2Fa.cn%2Fr%2F12", + Some(&sid_cookie()), + ) + .await; + assert_eq!(body_json(resp).await["code"], 40307); + } + + #[tokio::test] + async fn signature_matches_documented_sha1_string() { + let server = MockServer::start().await; + mount_wechat(&server).await; + + let resp = get( + app_with(&server.uri(), true), + "/api/wechat/jssdk-signature?url=https%3A%2F%2Fexample.com%2Fr%2F12", + Some(&sid_cookie()), + ) + .await; + let v = body_json(resp).await; + assert_eq!(v["code"], 0); + let data = &v["data"]; + assert_eq!(data["appId"], "wxapp"); + let expected = sha1_hex_of(&format!( + "jsapi_ticket=TICKET&noncestr={}×tamp={}&url=https://example.com/r/12", + data["nonceStr"].as_str().unwrap(), + data["timestamp"].as_u64().unwrap() + )); + assert_eq!(data["signature"], expected); + } + + #[tokio::test] + async fn fragment_is_stripped_before_signing() { + let server = MockServer::start().await; + mount_wechat(&server).await; + + let resp = get( + app_with(&server.uri(), true), + "/api/wechat/jssdk-signature?url=https%3A%2F%2Fexample.com%2Fr%2F12%23%2Ffoo", + Some(&sid_cookie()), + ) + .await; + let v = body_json(resp).await; + let data = &v["data"]; + let expected = sha1_hex_of(&format!( + "jsapi_ticket=TICKET&noncestr={}×tamp={}&url=https://example.com/r/12", + data["nonceStr"].as_str().unwrap(), + data["timestamp"].as_u64().unwrap() + )); + assert_eq!(data["signature"], expected); + } + + #[tokio::test] + async fn token_and_ticket_are_cached_across_calls() { + let server = MockServer::start().await; + // 各只允许命中一次:第二次调用必须走内存缓存 + Mock::given(method("GET")) + .and(path("/cgi-bin/token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"access_token": "AT", "expires_in": 7200})), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/cgi-bin/ticket/getticket")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errcode": 0, "errmsg": "ok", "ticket": "TICKET", "expires_in": 7200 + }))) + .expect(1) + .mount(&server) + .await; + + let app = app_with(&server.uri(), true); + let cookie = sid_cookie(); + let uri = "/api/wechat/jssdk-signature?url=https%3A%2F%2Fa.cn%2Fr%2F12"; + let first = body_json(get(app.clone(), uri, Some(&cookie)).await).await; + let second = body_json(get(app, uri, Some(&cookie)).await).await; + assert_eq!(first["code"], 0); + assert_eq!(second["code"], 0); + server.verify().await; + } + + #[tokio::test] + async fn upstream_errcode_propagates_as_5xxxx() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/cgi-bin/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "errcode": 40013, "errmsg": "invalid appid" + }))) + .mount(&server) + .await; + + let resp = get( + app_with(&server.uri(), true), + "/api/wechat/jssdk-signature?url=https%3A%2F%2Fa.cn%2Fr%2F12", + Some(&sid_cookie()), + ) + .await; + assert_eq!(body_json(resp).await["code"], 90013); // 50000 + 40013 + } +} diff --git a/backend/src/ws/routes.rs b/backend/src/ws/routes.rs index e7b26f7..5080ac6 100644 --- a/backend/src/ws/routes.rs +++ b/backend/src/ws/routes.rs @@ -422,6 +422,8 @@ mod tests { yk_allowed_hosts: vec!["www.yuketang.cn".into()], limits, log_dir: "logs".into(), + wechat_app_id: None, + wechat_app_secret: None, }; Arc::new(AppState::new( config, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 35be457..d1d13a1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,8 @@ - 创建房间对话框数值输入框无法删空重输:原 `Number(e.target.value) || 默认值` 使清空(空串 → 0 → falsy)立即回弹为默认值;改为字符串态保存、输入期间不校验不回弹,仅在提交时统一校验 ### Changed +- 房间页底栏:微信内置浏览器下于相机扫码按钮上方新增「微信扫一扫」主按钮(`docs/SPEC.md` §3.2.2 首选方案),结果与相机扫码同路径(本地预校验 → `share_qr` 推送全房间);非微信环境或微信侧不可用(未配置公众号 40307 / 签名失败 / 未注入 wx)时保持并提示回退相机扫码 +- `.env.example`:新增 `WECHAT_APP_ID` / `WECHAT_APP_SECRET` 占位(M5 微信内扫码;留空则微信内自动降级为相机扫码) - `startQrScan` 的 `onDetected` 回调返回值改为 `boolean`:`true` 表示接受结果并停止扫描,`false` 表示内容无效、继续扫描——调用方(`ScanOverlay`)在返回 `false` 时展示错误提示而不中断扫码 - 广场点击房间卡片「扫描二维码」/「加入房间」对话框提交后不进入房间页:三个加入入口统一等待 `joined` 帧后跳转(此前仅创建路径标记了加入意图);若目标房间已在房间内则直接进入;取消对话框即清除意图 - SharedWorker 内引用 `window` 导致静默崩溃、WS 从未建立(页面永远无法进入房间):`wsUrl()` 与默认定时器改用 `globalThis`(worker 环境无 `window`);注意 worker 代码更新后需关闭所有引用标签页才会重载 @@ -20,6 +22,8 @@ - 创建房间对话框「填写课程信息(可选)」折叠开关增加展开箭头:折叠时箭头周期性轻微下沉(`animate-hint-bounce`)提示可展开,展开后旋转 180° ### Added +- 微信 JS-SDK 扫码签到(M5)后端 `backend/src/wechat/`:`GET /api/wechat/jssdk-signature?url=` 返回 `{appId,timestamp,nonceStr,signature}`;`WechatClient` 内存缓存 `access_token` / `jsapi_ticket`(默认 7200s,提前 300s 刷新;`tokio::sync::RwLock` 保证不跨 `await` 持锁),签名串 `jsapi_ticket=..&noncestr=..×tamp=..&url=..` 取 **plain SHA1**(新增 `sha1` 依赖),`url` 自动去除 `#` 及其后部分;公众号凭证经 `WECHAT_APP_ID` / `WECHAT_APP_SECRET` 注入,未配置时返回 40307(不 panic) +- 微信 JS-SDK 扫码签到(M5)前端:`frontend/src/lib/wechat.ts`(`MicroMessenger` UA 检测、jweixin 1.6.0 动态注入且幂等复用、`wx.config` → `wx.scanQRCode` 封装,用户取消返回 `null`)与 `frontend/src/api/wechat.ts`(签名获取) - 查看当前课程(F5,M4)后端 `backend/src/courses/`:`GET /api/courses` 实时透传雨课堂「正在上课」课程(`/api/v3/classroom/on-lesson` ∩ `/v/course_meta/learning_list/`,对齐 `course_helper` 的 `getCoursesList()`),本站不落库;上游会话失效(50000)映射为 40101 统一登录引导 - 查看当前课程前端页面 `frontend/src/pages/Courses.tsx`:展示课程名 / 教师 / 课堂名(班级)/ 课程头像(无头像时以课程名首字兜底),支持手动刷新、加载/错误提示与无课程空态;导航栏新增「当前课程」入口(`/courses`) - 分享房间模块(F3,M3)后端 `backend/src/ws/`:`Hub` 内存态房间管理(创建/加入/密码校验+限速/成员列表/历史消息 FIFO≤100 条+有效期淘汰/自定义生命周期);`GET /ws` 握手校验本站会话、单用户单连接(旧连接 close 4009);`POST /api/rooms`、`DELETE /api/rooms/{id}`、`GET /api/plaza`;后台 sweep 任务(心跳假死判死、lobby 空闲 10 分钟回收 close 4000、消息频率超限 close 4008、房间到期/14 天无消息回收) diff --git a/frontend/src/api/wechat.ts b/frontend/src/api/wechat.ts new file mode 100644 index 0000000..e038c77 --- /dev/null +++ b/frontend/src/api/wechat.ts @@ -0,0 +1,14 @@ +import { api } from './client' + +/** 后端 /api/wechat/jssdk-signature 返回的签名参数(camelCase 直给 wx.config) */ +export interface JssdkSignature { + appId: string + timestamp: number + nonceStr: string + signature: string +} + +/** 取 JS-SDK 签名;url 必须是当前页面完整地址(# 之后部分由后端去除) */ +export async function fetchJssdkSignature(url: string): Promise { + return api.get(`/api/wechat/jssdk-signature?url=${encodeURIComponent(url)}`) +} \ No newline at end of file diff --git a/frontend/src/lib/wechat.test.ts b/frontend/src/lib/wechat.test.ts new file mode 100644 index 0000000..bd609c9 --- /dev/null +++ b/frontend/src/lib/wechat.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { JssdkSignature } from '../api/wechat' +import type { WechatJsSdk } from './wechat' + +const SIGNATURE: JssdkSignature = { + appId: 'wxapp', + timestamp: 1700000000, + nonceStr: 'abc123', + signature: 'sig', +} + +/** 精简版 wx 打桩:ready 同步回调,scanQRCode 按场景返回 */ +function fakeWx(options: { + ready?: boolean + result?: string + cancel?: boolean + fail?: unknown +}): WechatJsSdk { + const wx: WechatJsSdk = { + config: vi.fn(), + ready: vi.fn((cb: () => void) => { + if (options.ready !== false) cb() + }), + error: vi.fn((cb: (res: unknown) => void) => { + if (options.ready === false) cb({ errMsg: 'config:invalid signature' }) + }), + scanQRCode: vi.fn((opts) => { + if (options.cancel) opts.cancel?.({}) + else if (options.fail !== undefined) opts.fail?.(options.fail) + else opts.success?.({ resultStr: options.result ?? '' }) + }), + } + return wx +} + +function setWx(wx: WechatJsSdk | undefined) { + ;(globalThis as { wx?: WechatJsSdk }).wx = wx +} + +/** 取注入的 script(最后一个)并手动触发 onload(jsdom 不加载外链脚本) */ +function latestScript(): HTMLScriptElement { + const scripts = globalThis.document.head.querySelectorAll('script') + const last = scripts[scripts.length - 1] + if (!(last instanceof HTMLScriptElement)) throw new Error('未注入 script') + return last +} + +/** 每个用例重置模块内的 loading 缓存 */ +async function freshModule() { + vi.resetModules() + return await import('./wechat') +} + +beforeEach(() => { + setWx(undefined) + for (const s of globalThis.document.head.querySelectorAll('script')) s.remove() +}) + +afterEach(() => { + setWx(undefined) + vi.restoreAllMocks() +}) + +describe('isWechatBrowser', () => { + it('识别 MicroMessenger UA(含企业微信 webview)', async () => { + const { isWechatBrowser } = await freshModule() + expect( + isWechatBrowser( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) MicroMessenger/8.0.49', + ), + ).toBe(true) + expect(isWechatBrowser('Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36')).toBe(false) + }) +}) + +describe('loadJweixin', () => { + it('已存在 globalThis.wx 时直接复用且不注入脚本', async () => { + const { loadJweixin } = await freshModule() + const wx = fakeWx({}) + setWx(wx) + await expect(loadJweixin()).resolves.toBe(wx) + expect(globalThis.document.head.querySelectorAll('script').length).toBe(0) + }) + + it('注入 jweixin 脚本,onload 后返回 wx', async () => { + const { loadJweixin } = await freshModule() + const pending = loadJweixin() + const script = latestScript() + expect(script.src).toContain('jweixin-1.6.0.js') + + const wx = fakeWx({}) + setWx(wx) + script.dispatchEvent(new Event('load')) + await expect(pending).resolves.toBe(wx) + }) + + it('并发调用只注入一个脚本', async () => { + const { loadJweixin } = await freshModule() + const a = loadJweixin() + const b = loadJweixin() + expect(globalThis.document.head.querySelectorAll('script').length).toBe(1) + + const wx = fakeWx({}) + setWx(wx) + latestScript().dispatchEvent(new Event('load')) + await expect(a).resolves.toBe(wx) + await expect(b).resolves.toBe(wx) + }) + + it('脚本加载失败时 reject', async () => { + const { loadJweixin } = await freshModule() + const pending = loadJweixin() + latestScript().dispatchEvent(new Event('error')) + await expect(pending).rejects.toThrow('微信 JS-SDK 加载失败') + }) +}) + +describe('wechatScanQrCode', () => { + it('config 走通后返回扫码结果', async () => { + const { wechatScanQrCode, loadJweixin } = await freshModule() + const wx = fakeWx({ result: 'https://www.yuketang.cn/c/abc' }) + setWx(wx) + + await expect(wechatScanQrCode(SIGNATURE)).resolves.toBe('https://www.yuketang.cn/c/abc') + expect(wx.config).toHaveBeenCalledWith( + expect.objectContaining({ appId: 'wxapp', nonceStr: 'abc123', jsApiList: ['scanQRCode'] }), + ) + // 复用已注入的 wx,不重复加载 + await expect(loadJweixin()).resolves.toBe(wx) + }) + + it('用户取消返回 null', async () => { + const { wechatScanQrCode } = await freshModule() + setWx(fakeWx({ cancel: true })) + await expect(wechatScanQrCode(SIGNATURE)).resolves.toBeNull() + }) + + it('结果为空白字符串时按取消处理', async () => { + const { wechatScanQrCode } = await freshModule() + setWx(fakeWx({ result: ' ' })) + await expect(wechatScanQrCode(SIGNATURE)).resolves.toBeNull() + }) + + it('config 校验失败时 reject 并携带 errMsg', async () => { + const { wechatScanQrCode } = await freshModule() + setWx(fakeWx({ ready: false })) + await expect(wechatScanQrCode(SIGNATURE)).rejects.toThrow('config:invalid signature') + }) + + it('scanQRCode 失败时 reject', async () => { + const { wechatScanQrCode } = await freshModule() + setWx(fakeWx({ fail: { errMsg: 'scanQRCode:fail permission denied' } })) + await expect(wechatScanQrCode(SIGNATURE)).rejects.toThrow( + 'scanQRCode:fail permission denied', + ) + }) +}) \ No newline at end of file diff --git a/frontend/src/lib/wechat.ts b/frontend/src/lib/wechat.ts new file mode 100644 index 0000000..b9b6cbd --- /dev/null +++ b/frontend/src/lib/wechat.ts @@ -0,0 +1,104 @@ +// 微信内 JS-SDK:UA 检测 + jweixin 动态注入 + 扫一扫(docs/SPEC.md §3.2.2 首选方案) +// 非微信环境、未配置公众号(后端 40307)或注入失败时抛错,调用方降级为相机扫码/相册识别。 +// 该模块可能运行在 Worker 环境(历史坑),统一用 globalThis 访问全局对象。 + +import type { JssdkSignature } from '../api/wechat' + +/** 官方 CDN 版 jweixin(1.6.0 为当前稳定版) */ +const JWEIXIN_SRC = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js' + +export interface WxConfigOptions { + debug: boolean + appId: string + timestamp: number + nonceStr: string + signature: string + jsApiList: string[] +} + +export interface WxScanQrCodeOptions { + needResult: 0 | 1 + scanType?: string[] + success?: (res: { resultStr: string }) => void + fail?: (res: unknown) => void + cancel?: (res: unknown) => void +} + +export interface WechatJsSdk { + config(options: WxConfigOptions): void + ready(callback: () => void): void + error(callback: (res: unknown) => void): void + scanQRCode(options: WxScanQrCodeOptions): void +} + +/** globalThis.wx(避免 window 以兼容 Worker/非浏览器环境) */ +function getWx(): WechatJsSdk | undefined { + return (globalThis as { wx?: WechatJsSdk }).wx +} + +/** 是否微信内置浏览器(企业微信/公众号 webview 都带 MicroMessenger 标识) */ +export function isWechatBrowser(ua: string = globalThis.navigator.userAgent): boolean { + return /micromessenger/i.test(ua) +} + +let loading: Promise | null = null + +/** 动态注入 jweixin(幂等:已注入或已在加载中直接复用) */ +export function loadJweixin(): Promise { + const existing = getWx() + if (existing) return Promise.resolve(existing) + if (loading) return loading + + const promise = new Promise((resolve, reject) => { + const fail = (msg: string) => { + loading = null // 允许后续重试 + reject(new Error(msg)) + } + const script = globalThis.document.createElement('script') + script.src = JWEIXIN_SRC + script.async = true + script.onload = () => { + const wx = getWx() + if (wx) resolve(wx) + else fail('微信 JS-SDK 加载失败') + } + script.onerror = () => fail('微信 JS-SDK 加载失败') + globalThis.document.head.appendChild(script) + }) + loading = promise + return promise +} + +/** 微信「扫一扫」:config → ready 后调用 scanQRCode;用户取消返回 null */ +export async function wechatScanQrCode(signature: JssdkSignature): Promise { + const wx = await loadJweixin() + + await new Promise((resolve, reject) => { + wx.config({ + debug: false, + appId: signature.appId, + timestamp: signature.timestamp, + nonceStr: signature.nonceStr, + signature: signature.signature, + jsApiList: ['scanQRCode'], + }) + wx.ready(() => resolve()) + wx.error((res) => reject(new Error(describeWxError(res)))) + }) + + return new Promise((resolve, reject) => { + wx.scanQRCode({ + needResult: 1, + scanType: ['qrCode', 'barCode'], + success: (res) => resolve(res.resultStr.trim() === '' ? null : res.resultStr), + cancel: () => resolve(null), + fail: (res) => reject(new Error(describeWxError(res))), + }) + }) +} + +/** 从 wx 回调结果里取可读错误(errMsg 形如 "scanQRCode:fail xxx") */ +function describeWxError(res: unknown): string { + const msg = (res as { errMsg?: unknown } | null)?.errMsg + return typeof msg === 'string' && msg !== '' ? msg : '微信接口调用失败' +} \ No newline at end of file diff --git a/frontend/src/pages/Room.tsx b/frontend/src/pages/Room.tsx index 9afec08..1f3ba65 100644 --- a/frontend/src/pages/Room.tsx +++ b/frontend/src/pages/Room.tsx @@ -2,7 +2,7 @@ // 已在目标房间 → 直接渲染房间页;否则先申请加入(密码房弹密码框,joined 后切到房间页)——刷新不会丢房间 // 顶栏:左返回 / 正中房间名(大字)+ 房号(小字)/ 右设置 // 中间:接收的签到码消息流(默认点击 URL 框才签到) -// 底栏:全屏扫码分享(左下角相册可上传图片本地识别)+ 分享房间 +// 底栏:扫码分享(微信内优先「微信扫一扫」,非微信/失败用全屏相机,左下角相册可本地识别图片)+ 分享房间 // 设置抽屉:房间信息、成员、「收到消息立即签到」开关、离开/关闭房间 import { useEffect, useRef, useState } from 'react' @@ -10,9 +10,11 @@ import { Link, useNavigate, useParams } from 'react-router-dom' import { ApiError } from '../api/client' import { closeRoom } from '../api/room' import { submitSign } from '../api/sign' +import { fetchJssdkSignature } from '../api/wechat' import { decodeQrFromImage, startQrScan, type ScannerHandle } from '../lib/qr-scan' import { inviteLink } from '../lib/room-link' import { isYuketangSignUrl } from '../lib/sign-url' +import { isWechatBrowser, wechatScanQrCode } from '../lib/wechat' import { useNow } from '../lib/use-now' import { useAuth } from '../stores/auth' import { useRoom } from '../stores/room' @@ -580,7 +582,10 @@ function InRoomView() { const [autoSign, setAutoSign] = useAutoSign() const [showSettings, setShowSettings] = useState(false) const [scanning, setScanning] = useState(false) + const [wxScanning, setWxScanning] = useState(false) const [toast, setToast] = useState(null) + /** 微信内才展示「微信扫一扫」主按钮(非微信环境保持相机扫码) */ + const inWechat = isWechatBrowser() // 自动签到:监听 qr_update 新消息,开启时立即提交(去重,只签一次) const signedRef = useRef(new Set()) @@ -639,6 +644,29 @@ function InRoomView() { } } + /** 微信内「扫一扫」:签名 → wx.config → scanQRCode,结果与相机扫码同路径推送全房间 */ + async function wechatScan() { + if (wxScanning) return + setWxScanning(true) + setToast(null) + try { + const signature = await fetchJssdkSignature(globalThis.location.href) + const raw = (await wechatScanQrCode(signature))?.trim() + if (!raw) return // 用户取消 + if (!isYuketangSignUrl(raw)) { + setToast(INVALID_QR_TEXT) + return + } + pushQr(raw) + setToast('已推送到房间') + } catch (e) { + // 后端未配置公众号(40307)或微信侧校验失败 → 提示改用相机扫码 + setToast(e instanceof ApiError ? e.message : '微信扫一扫不可用,请改用相机扫码') + } finally { + setWxScanning(false) + } + } + return ( <> {/* 顶栏:左返回 / 中房间名(大字)+ 房号(小字)/ 右设置 */} @@ -675,8 +703,21 @@ function InRoomView() { - {/* 底栏:扫码(主位)+ 分享房间 */} + {/* 底栏:微信内「微信扫一扫」主位 + 相机扫码 + 分享房间 */}
+ {inWechat && ( + + )}