Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 24 additions & 19 deletions backend/src/ws/hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,12 @@ impl Hub {
}

/// 向 lobby 连接广播广场全量列表
/// (房间内连接进房后会退订 lobby,故此处的接收者数即 lobby 在线数)
pub fn broadcast_plaza(&self) {
// 无 lobby 订阅者(所有人都在房间内 / 无人在线)时无需构建全量列表
if self.lobby_tx.receiver_count() == 0 {
return;
}
let msg = ServerMsg::PlazaUpdate {
rooms: self.plaza(),
};
Expand Down Expand Up @@ -483,7 +488,8 @@ impl Hub {

/// 扫码者推送二维码(docs/DESIGN.md §4.2 share_qr)。
/// 内容合法性(白名单 URL)由路由层先行校验,Hub 仅做成员与长度检查。
pub fn share_qr(&self, conn_id: ConnId, raw: &str, now: Now) -> Result<ServerMsg, WsError> {
/// 返回 `()`:广播内容由 `room.tx` 下发,调用方无需再持有一份拷贝。
pub fn share_qr(&self, conn_id: ConnId, raw: &str, now: Now) -> Result<(), WsError> {
let mut g = self.inner.lock().expect("hub lock");
let Some(conn) = g.conns.get(&conn_id) else {
return Err(WsError::new(error_code::BAD_REQUEST, "连接不存在"));
Expand All @@ -500,9 +506,11 @@ impl Hub {
.get_mut(&room_id)
.expect("conn.room points to live room");
let expire_unix_ms = now.unix_ms + room.qr_ttl.as_millis() as u64;
// 内容只分配两份:一份留存房间历史,一份随广播下发(此前为 3 次分配)
let stored = raw.to_string();
room.push_msg(
QrMsg {
raw: raw.to_string(),
raw: stored.clone(),
by: user_id,
expire_at: now.instant + room.qr_ttl,
expire_unix_ms,
Expand All @@ -511,14 +519,13 @@ impl Hub {
now,
);
room.last_activity = now.instant;
let msg = ServerMsg::QrUpdate {
let _ = room.tx.send(ServerMsg::QrUpdate {
room: room_id,
raw: raw.to_string(),
raw: stored,
by: user_id,
expire_at: expire_unix_ms,
};
let _ = room.tx.send(msg.clone());
Ok(msg)
});
Ok(())
}

/// 成员签到回执(docs/DESIGN.md §4.2 sign_result),广播给全房间
Expand Down Expand Up @@ -926,16 +933,18 @@ mod tests {
.unwrap();
// 上限:3600s 后过期;下限:1s 后过期
hub.join(a.id, id_big, None, now_at(t, ms));
let msg = hub.share_qr(a.id, URL, now_at(t, ms)).unwrap();
match msg {
let mut big_rx = hub.subscribe_room(id_big).unwrap();
hub.share_qr(a.id, URL, now_at(t, ms)).unwrap();
match big_rx.try_recv().unwrap() {
ServerMsg::QrUpdate { expire_at, .. } => {
assert_eq!(expire_at, ms + 3_600_000)
}
other => panic!("{other:?}"),
}
hub.join(a.id, id_zero, None, now_at(t, ms));
let msg = hub.share_qr(a.id, URL, now_at(t, ms)).unwrap();
match msg {
let mut zero_rx = hub.subscribe_room(id_zero).unwrap();
hub.share_qr(a.id, URL, now_at(t, ms)).unwrap();
match zero_rx.try_recv().unwrap() {
ServerMsg::QrUpdate { expire_at, .. } => assert_eq!(expire_at, ms + 1_000),
other => panic!("{other:?}"),
}
Expand Down Expand Up @@ -1198,8 +1207,8 @@ mod tests {
.unwrap();
hub.join(a.id, id, None, now_at(t, ms));
let mut room_rx = hub.subscribe_room(id).unwrap();
let msg = hub.share_qr(a.id, URL, now_at(t, ms)).unwrap();
match msg {
hub.share_qr(a.id, URL, now_at(t, ms)).unwrap();
match room_rx.try_recv().unwrap() {
ServerMsg::QrUpdate {
room,
raw,
Expand All @@ -1211,10 +1220,6 @@ mod tests {
}
other => panic!("{other:?}"),
}
assert!(matches!(
room_rx.try_recv().unwrap(),
ServerMsg::QrUpdate { .. }
));

// 新成员加入收到未过期历史消息
let b = mk_conn(&hub, 2, t, ms);
Expand Down Expand Up @@ -1307,13 +1312,13 @@ mod tests {
// 未加入房间
match hub.share_qr(a.id, URL, now_at(t, ms)) {
Err(e) => assert_eq!(e.code, error_code::NOT_IN_ROOM),
Ok(m) => panic!("{m:?}"),
Ok(()) => panic!("unexpected ok when not in room"),
}
hub.join(a.id, id, None, now_at(t, ms));
// 超长
match hub.share_qr(a.id, &"x".repeat(33), now_at(t, ms)) {
Err(e) => assert_eq!(e.code, error_code::MSG_TOO_LARGE),
Ok(m) => panic!("{m:?}"),
Ok(()) => panic!("unexpected ok for oversized raw"),
}
}

Expand Down
20 changes: 15 additions & 5 deletions backend/src/ws/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ async fn connection(
let (outbox_tx, mut outbox_rx) = mpsc::unbounded_channel::<Outbound>();
let (close_tx, mut close_rx) = mpsc::unbounded_channel::<u16>();
let conn_id = hub.register(user_id, outbox_tx, close_tx.clone(), Now::real());
let mut lobby_rx = hub.subscribe_lobby();
// lobby 订阅仅在「不在任何房间」时持有:进房即退订,把广场全量广播的代价限定在广场页连接上
let mut lobby_rx = Some(hub.subscribe_lobby());

let (mut sink, mut stream) = socket.split();
let mut seq: u64 = 0;
Expand Down Expand Up @@ -155,8 +156,17 @@ async fn connection(
out = outbox_rx.recv() => {
match out {
None => break,
Some(Outbound::Subscribe(rx)) => room_rx = Some(rx),
Some(Outbound::Unsubscribe) => room_rx = None,
Some(Outbound::Subscribe(rx)) => {
room_rx = Some(rx);
lobby_rx = None; // 进房:不再接收广场广播
}
Some(Outbound::Unsubscribe) => {
room_rx = None;
// 回到 lobby:重新订阅(其间错过的 plaza_update 由前端 REST 拉取兜底)
if lobby_rx.is_none() {
lobby_rx = Some(hub.subscribe_lobby());
}
}
}
}
ev = async { room_rx.as_mut().expect("precondition").recv().await }, if room_rx.is_some() => {
Expand All @@ -175,7 +185,7 @@ async fn connection(
Err(broadcast::error::RecvError::Closed) => room_rx = None,
}
}
ev = lobby_rx.recv() => {
ev = async { lobby_rx.as_mut().expect("precondition").recv().await }, if lobby_rx.is_some() => {
match ev {
Ok(m) => {
if !send_msg(&mut sink, &mut seq, m).await {
Expand Down Expand Up @@ -260,7 +270,7 @@ async fn handle_client_msg(
return send_msg(sink, seq, err).await;
}
match hub.share_qr(conn_id, &raw, Now::real()) {
Ok(_) => true,
Ok(()) => true,
Err(e) => {
send_msg(
sink,
Expand Down
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
- 通用对话框卡片限高 `85vh` 且内容区内部滚动(`overscroll-contain`),避免小屏或展开课程信息后弹窗超出屏幕
- 广场房间卡片详情对话框的主按钮由「扫描二维码」改为「加入房间」,与底部操作条语义一致(加入后进入房间页,在房间内扫码分享)
- 创建房间对话框「填写课程信息(可选)」折叠开关增加展开箭头:折叠时箭头周期性轻微下沉(`animate-hint-bounce`)提示可展开,展开后旋转 180°
- 性能优化(不改变对外行为):
- 房间页消息流:签到码卡片 `QrCard` 加 `memo`(父级每秒重渲时不再整列重渲,倒计时仍由各卡片自身 tick);设置抽屉改为逐字段订阅 store(消息流/签到回执变化不再连带重渲抽屉);自动签到只处理消息尾部增量(原先每次消息事件都重建全量 `Set`)
- 扫码相机 `BarcodeDetector` 原生路径按 `FRAME_INTERVAL_MS`(300ms)节流,此前走 `requestAnimationFrame` 每帧都执行 `detect`,白耗 CPU/GPU(jsQR 路径原本已节流)
- 房间消息 store 在入队时惰性裁剪已过期二维码消息,使数组有界(对齐后端 `VecDeque` 的惰性淘汰),长会话不再无界增长
- 后端 WS:连接进房后退订 lobby 广播、回广场时重新订阅,使广场全量 `plaza_update` 只发给广场页连接;`broadcast_plaza` 在无 lobby 订阅者时提前返回;`share_qr` 内容分配由 3 次降为 2 次

### Added
- 微信 JS-SDK 可用性预检查(M5):后端 `GET /api/wechat/status`(免会话)返回 `{available, reason}`,只暴露「是否已配置」不含凭证;前端房间页仅在「微信内 且 后端判定可用」时才渲染「微信扫一扫」入口,未配置时完全不显示(避免点击后才报 40307)
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/lib/qr-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function decodeByJsQr(img: HTMLImageElement): Promise<string> {
/** 相机帧解码前的最大边长:大分辨率帧先等比缩小,控制每帧解码耗时 */
const CAMERA_DECODE_EDGE = 1280

/** 每帧解码间隔(ms):识别失败/内容被拒时继续下一帧 */
/** 每帧解码间隔(ms):识别失败/内容被拒时继续下一帧(native/jsQR 两路统一节流) */
const FRAME_INTERVAL_MS = 300

/**
Expand Down Expand Up @@ -114,7 +114,7 @@ async function startNative(
): Promise<ScannerHandle> {
const detector = new window.BarcodeDetector!({ formats: ['qr_code'] })
let stopped = false
let raf = 0
let timer = 0
const tick = async () => {
if (stopped) return
try {
Expand All @@ -127,11 +127,13 @@ async function startNative(
} catch {
// 单帧解码失败忽略,继续下一帧
}
raf = requestAnimationFrame(() => void tick())
// 按 FRAME_INTERVAL_MS 节流(此前每帧 rAF 都跑 detect,白耗 CPU/GPU)
if (stopped) return // detect 期间被 stop:不再排队下一帧
timer = window.setTimeout(() => void tick(), FRAME_INTERVAL_MS)
}
function stop() {
stopped = true
cancelAnimationFrame(raf)
window.clearTimeout(timer)
stopStream(stream)
}
void tick()
Expand Down
39 changes: 30 additions & 9 deletions frontend/src/pages/Room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// 底栏:扫码分享(微信内优先「微信扫一扫」,非微信/失败用全屏相机,左下角相册可本地识别图片)+ 分享房间
// 设置抽屉:房间信息、成员、「收到消息立即签到」开关、离开/关闭房间

import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ApiError } from '../api/client'
import { closeRoom } from '../api/room'
Expand Down Expand Up @@ -149,7 +149,16 @@ async function signAndBroadcast(raw: string): Promise<string> {
}
}

function QrCard({ raw, by, expireAt }: { raw: string; by: number; expireAt: number }) {
/** 单条签到码卡片。memo:父级消息流每秒重渲时不重复渲染(倒计时由卡片自身 tick) */
const QrCard = memo(function QrCard({
raw,
by,
expireAt,
}: {
raw: string
by: number
expireAt: number
}) {
const now = useNow(1000)
const [signing, setSigning] = useState(false)
const [result, setResult] = useState<string | null>(null)
Expand Down Expand Up @@ -198,7 +207,7 @@ function QrCard({ raw, by, expireAt }: { raw: string; by: number; expireAt: numb
</div>
</li>
)
}
})

/** 中间消息流:签到码卡片(最新在底部,自动滚动)+ 签到回执 */
function MessageStream() {
Expand Down Expand Up @@ -279,7 +288,12 @@ function SettingsPanel({
setAutoSign: (v: boolean) => void
onClose: () => void
}) {
const { room, name, owner, members, meta } = useRoom()
// 逐字段订阅:设置面板只依赖房间元信息,消息流/签到回执变化不应重渲抽屉
const room = useRoom((s) => s.room)
const name = useRoom((s) => s.name)
const owner = useRoom((s) => s.owner)
const members = useRoom((s) => s.members)
const meta = useRoom((s) => s.meta)
const userId = useAuth((s) => s.userId)
const clearRoom = useRoom((s) => s.clearRoom)
const navigate = useNavigate()
Expand Down Expand Up @@ -597,21 +611,28 @@ function InRoomView() {
}, [inWechat])

// 自动签到:监听 qr_update 新消息,开启时立即提交(去重,只签一次)
// 只处理尾部新增(消息对象引用稳定、裁剪只动队首),避免每次事件都重建全量 Set
const signedRef = useRef(new Set<string>())
const lastSeenRef = useRef<QrMsg | null>(null)
const autoSignRef = useRef(autoSign)
useEffect(() => {
autoSignRef.current = autoSign
}, [autoSign])
useEffect(() => {
return useRoom.subscribe((s, prev) => {
if (!autoSignRef.current || s.messages === prev.messages) return
const prevKeys = new Set(prev.messages.map(qrKey))
for (const m of s.messages) {
return useRoom.subscribe((s) => {
if (!autoSignRef.current) return
const msgs = s.messages
const prevLast = lastSeenRef.current
// 上一批的最后一条已被裁掉(未找到)时从头遍历,signedRef 保证不会重复提交
const from = prevLast === null ? 0 : msgs.lastIndexOf(prevLast) + 1
for (let i = from; i < msgs.length; i += 1) {
const m = msgs[i]
const key = qrKey(m)
if (prevKeys.has(key) || signedRef.current.has(key)) continue
if (signedRef.current.has(key)) continue
signedRef.current.add(key)
void signAndBroadcast(m.raw)
}
lastSeenRef.current = msgs.length > 0 ? msgs[msgs.length - 1] : null
})
}, [])

Expand Down
22 changes: 19 additions & 3 deletions frontend/src/stores/room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,27 @@ describe('applyFrameToState', () => {
it('qr_update 追加新消息并对相同内容去重', () => {
const st = baseState()
const f = fr({ type: 'qr_update', room: 7, raw: 'r1', by: 2, expire_at: 100 })
expect(applyFrameToState(st, f).messages).toEqual([{ raw: 'r1', by: 2, expire_at: 100 }])
expect(applyFrameToState(st, f, 0).messages).toEqual([{ raw: 'r1', by: 2, expire_at: 100 }])

const withOne: RoomState = { ...st, messages: [{ raw: 'r1', by: 2, expire_at: 100 }] }
expect(applyFrameToState(withOne, f).messages).toHaveLength(1)
expect(applyFrameToState(withOne, fr({ type: 'qr_update', room: 7, raw: 'r2', by: 2, expire_at: 100 })).messages).toHaveLength(2)
// 重复回显且无过期项 → 空补丁(messages 引用不变,避免无谓重渲)
expect(applyFrameToState(withOne, f, 0)).toEqual({})
expect(applyFrameToState(withOne, fr({ type: 'qr_update', room: 7, raw: 'r2', by: 2, expire_at: 100 }), 0).messages).toHaveLength(2)
})

it('qr_update 入队时惰性裁剪已过期消息', () => {
const st: RoomState = {
...baseState(),
messages: [
{ raw: 'old', by: 1, expire_at: 50 },
{ raw: 'live', by: 2, expire_at: 500 },
],
}
const out = applyFrameToState(st, fr({ type: 'qr_update', room: 7, raw: 'new', by: 3, expire_at: 900 }), 100)
expect(out.messages).toEqual([
{ raw: 'live', by: 2, expire_at: 500 },
{ raw: 'new', by: 3, expire_at: 900 },
])
})

it('sign_result 前插并保留最近 20 条', () => {
Expand Down
24 changes: 17 additions & 7 deletions frontend/src/stores/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ function withoutRoom(): Partial<RoomState> {
}
}

export function applyFrameToState(state: RoomState, frame: ServerFrame): Partial<RoomState> {
/** 帧 → 状态补丁。now 用于惰性裁剪已过期二维码消息(默认取当前时间,测试可注入) */
export function applyFrameToState(
state: RoomState,
frame: ServerFrame,
now: number = Date.now(),
): Partial<RoomState> {
switch (frame.type) {
case 'joined':
return {
Expand All @@ -69,14 +74,19 @@ export function applyFrameToState(state: RoomState, frame: ServerFrame): Partial
case 'member_leave':
if (frame.room !== state.room) return {}
return { owner: frame.owner, members: frame.members }
case 'qr_update':
case 'qr_update': {
if (frame.room !== state.room) return {}
// 发送者也会收到回显;按 raw+expire_at 去重,避免重复追加
return {
messages: state.messages.some((m) => m.raw === frame.raw && m.expire_at === frame.expire_at)
? state.messages
: [...state.messages, { raw: frame.raw, by: frame.by, expire_at: frame.expire_at }],
}
const duplicated = state.messages.some(
(m) => m.raw === frame.raw && m.expire_at === frame.expire_at,
)
// 入队时惰性淘汰已过期消息,使数组有界(对齐后端 VecDeque 的惰性淘汰)
const messages = state.messages.some((m) => m.expire_at <= now)
? state.messages.filter((m) => m.expire_at > now)
: state.messages
if (duplicated) return messages === state.messages ? {} : { messages }
return { messages: [...messages, { raw: frame.raw, by: frame.by, expire_at: frame.expire_at }] }
}
case 'sign_result':
if (frame.room !== state.room) return {}
return {
Expand Down
Loading