From 0267d82939745d1b6dbb082524857b3ebfdc8f2d Mon Sep 17 00:00:00 2001 From: Alumos <1281610535@qq.com> Date: Fri, 13 Nov 2026 13:11:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(student-calls):=20=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=91=BC=E5=8F=AB=E8=80=81=E5=B8=88=E4=B8=8E?= =?UTF-8?q?=E6=95=99=E5=B8=88=E5=85=B1=E4=BA=AB=E5=B7=B2=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 大屏端:绑定后 Windows 桌面显示「呼叫老师」原生悬浮入口,二次确认后以设备身份呼叫本班全部关联老师;取消/Escape 不发送,支持多显示器拖动、DPI 缩放与任务栏避让,全屏展示期间隐藏,托盘后仍可用。 - 教师端:侧栏「发通知」下方新增「学生呼叫」入口与未读红色数量,网页在线时通过 WS 提醒,断线重连与定时刷新补齐,明确标记后共享已读并保留处理人与时间,不自动已读。 - 服务端:新增 POST /device/student-calls、GET /student-calls、POST /student-calls/{id}/read;request_id 幂等、同班 30 秒限流、按班级成员鉴权,记录持久化到 SQLite 并纳入备份,旧备份缺少该集合仍可恢复。 - 契约与文档:重新生成 OpenAPI/WS 类型,补充 INTEGRATION、ARCHITECTURE、LOCAL_TESTING 及各端 README。 - 版本升至 0.3.5,新增发布说明。 --- .prettierignore | 1 + CHANGELOG.md | 4 + README.md | 1 + api/openapi.yaml | 299 ++++++++++++-- api/ws/frame.schema.json | 44 ++ deploy/.env.example | 2 +- deploy/compose.1panel.yaml | 2 +- deploy/compose.yaml | 2 +- docs/ARCHITECTURE.md | 2 +- docs/INTEGRATION.md | 10 +- docs/LOCAL_TESTING.md | 14 +- docs/releases/v0.3.5.md | 24 ++ package.json | 4 +- screen/README.md | 10 + screen/app.go | 8 +- screen/frontend/package.json | 2 +- screen/frontend/src/main.tsx | 48 +++ screen/frontend/src/native.ts | 5 +- screen/frontend/src/service.ts | 120 +++++- screen/frontend/src/ui.css | 59 +++ .../tests/native-student-call.test.ts | 159 ++++++++ screen/frontend/tests/service.test.ts | 132 ++++++ screen/student_call.go | 181 +++++++++ screen/student_call_other.go | 11 + screen/student_call_test.go | 131 ++++++ screen/student_call_windows.go | 380 +++++++++++++++++ screen/wails.json | 2 +- scripts/api-generator/package.json | 2 +- scripts/generate-contract.mjs | 25 ++ server/README.md | 6 + server/internal/app/app.go | 3 + server/internal/app/backup.go | 44 +- server/internal/app/classes.go | 5 + server/internal/app/hub.go | 2 + server/internal/app/models.go | 80 ++-- server/internal/app/realtime.go | 1 + server/internal/app/routes.go | 3 + server/internal/app/student_calls.go | 203 ++++++++++ .../internal/app/student_calls_backup_test.go | 136 +++++++ .../app/student_calls_realtime_test.go | 83 ++++ server/internal/app/student_calls_test.go | 382 ++++++++++++++++++ server/internal/repo/store.go | 4 +- tests/e2e/homework.spec.ts | 11 +- tests/e2e/student-calls-integration.spec.ts | 162 ++++++++ tests/e2e/student-calls.spec.ts | 218 ++++++++++ web/apps/admin/package.json | 2 +- web/apps/teacher/README.md | 1 + web/apps/teacher/package.json | 2 +- web/apps/teacher/src/App.tsx | 62 ++- web/apps/teacher/src/auth.tsx | 3 + web/apps/teacher/src/hooks.ts | 46 ++- web/apps/teacher/src/models.ts | 3 + web/apps/teacher/src/pages/StudentCalls.tsx | 151 +++++++ web/apps/teacher/src/studentCalls.test.ts | 62 +++ web/apps/teacher/src/studentCalls.ts | 53 +++ web/packages/api/package.json | 2 +- web/packages/api/src/types.ts | 307 ++++++++++---- web/packages/api/src/ws-types.ts | 12 + web/packages/ui/package.json | 2 +- 59 files changed, 3553 insertions(+), 182 deletions(-) create mode 100644 docs/releases/v0.3.5.md create mode 100644 screen/frontend/tests/native-student-call.test.ts create mode 100644 screen/student_call.go create mode 100644 screen/student_call_other.go create mode 100644 screen/student_call_test.go create mode 100644 screen/student_call_windows.go create mode 100644 server/internal/app/student_calls.go create mode 100644 server/internal/app/student_calls_backup_test.go create mode 100644 server/internal/app/student_calls_realtime_test.go create mode 100644 server/internal/app/student_calls_test.go create mode 100644 tests/e2e/student-calls-integration.spec.ts create mode 100644 tests/e2e/student-calls.spec.ts create mode 100644 web/apps/teacher/src/pages/StudentCalls.tsx create mode 100644 web/apps/teacher/src/studentCalls.test.ts create mode 100644 web/apps/teacher/src/studentCalls.ts diff --git a/.prettierignore b/.prettierignore index d3d676b..81ea5ca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,7 @@ web/packages/api/src/ws-types.ts **/dist/ server/static/ node_modules/ +.agent/ .pnpm-store/ tools/ .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6d38c..daf26e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 未发布 +## v0.3.5 — 2026-11-13 + +- 新增学生呼叫:Windows 桌面悬浮入口二次确认后呼叫本班全部关联教师;教师工作台新增「学生呼叫」、未读红色数量、在线实时提醒和手动共享已读,保留处理老师与时间。记录随服务持久化及备份,支持离线后补查;同班 30 秒防重复呼叫。不使用 Web Push,Windows 系统集成仍需实机验收。 + - 修复管理后台手机端成功/错误提示遮挡顶部导航的问题:窄屏提示改为底部显示,桌面保留右上角,并支持手动关闭。新增提示显示期间的窄屏导航回归测试,覆盖 320/390px 与 100%/150% 字号。 ## v0.3.4 — 2026-11-04 diff --git a/README.md b/README.md index 9f7abb9..37b91e9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ pnpm dev:screen # 大屏浏览器预览,默认 http://localhost:5175 - 服务端提供 REST 与 WebSocket、设备通知队列、回执和本地录音存储;大屏已有浏览器预览与 Windows 客户端代码,Windows 安装、系统音量、置顶和开机启动等仍需在目标设备上测试。 - 当前没有服务端语音识别或 TTS 服务:录音按原声发送,文字由大屏尝试使用系统语音播报。系统配置提供全站 ZIP 手动导出和恢复,并支持下发设备地址迁移计划;自动更新与定时备份尚未实现。大屏其余限制见 [大屏说明](screen/README.md)。 - 教师支持布置各科作业,Windows 客户端用独立原生桌面悬浮窗显示,可在后台和电脑上调整显示及置顶;设备实时接收完整作业快照,并在重连时补齐。 +- 学生可在 Windows 原生桌面「呼叫老师」入口二次确认,通知当前班级所有关联老师;教师端「学生呼叫」显示持久记录、未读红色数量和网页在线提醒,显式标记后共享已读并保留处理人及时间。不会在进入页面时自动已读,不使用 Web Push;同班 30 秒防重复呼叫。悬浮入口系统行为仍需 Windows 实机验收。 - 学校创建与教师 Excel 导入、后台客户端版本管理、账号注销尚未实现;学校角色隔离已有基础代码与测试,完整学校采购流程尚未开放。 - SQLite 数据在服务启动时加载到内存并由单个进程写入,**同一数据目录只能运行一个主服务实例**。尚未完成规范化仓储和大规模性能验证。优先使用管理后台的全站备份,或停服后备份完整数据目录及录音。 diff --git a/api/openapi.yaml b/api/openapi.yaml index b02e875..7cedb24 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "课堂叮咚 API", - "version": "0.3.4" + "version": "0.3.5" }, "servers": [ { @@ -1757,7 +1757,7 @@ } }, "post": { - "operationId": "post__messages_37", + "operationId": "post__messages_40", "summary": "POST /messages", "security": [ { @@ -1798,9 +1798,167 @@ } } }, + "/device/student-calls": { + "post": { + "operationId": "post__device_student_calls_37", + "summary": "POST /device/student-calls", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.:-]+$" + } + }, + "required": [ + "request_id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudentCall" + } + } + } + }, + "default": { + "description": "业务错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/student-calls": { + "get": { + "operationId": "get__student_calls_38", + "summary": "GET /student-calls", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudentCallList" + } + } + } + }, + "default": { + "description": "业务错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "class_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ] + } + }, + "/student-calls/{id}/read": { + "post": { + "operationId": "post__student_calls__id__read_39", + "summary": "POST /student-calls/{id}/read", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudentCall" + } + } + } + }, + "default": { + "description": "业务错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/messages/{id}": { "get": { - "operationId": "get__messages__id__38", + "operationId": "get__messages__id__41", "summary": "GET /messages/{id}", "security": [ { @@ -1841,7 +1999,7 @@ } }, "delete": { - "operationId": "delete__messages__id__39", + "operationId": "delete__messages__id__42", "summary": "DELETE /messages/{id}", "security": [ { @@ -1884,7 +2042,7 @@ }, "/messages/{id}/recall": { "post": { - "operationId": "post__messages__id__recall_40", + "operationId": "post__messages__id__recall_43", "summary": "POST /messages/{id}/recall", "security": [ { @@ -1927,7 +2085,7 @@ }, "/messages/{id}/resend": { "post": { - "operationId": "post__messages__id__resend_41", + "operationId": "post__messages__id__resend_44", "summary": "POST /messages/{id}/resend", "security": [ { @@ -1970,7 +2128,7 @@ }, "/phrases": { "get": { - "operationId": "get__phrases_42", + "operationId": "get__phrases_45", "summary": "GET /phrases", "security": [ { @@ -2004,7 +2162,7 @@ } }, "post": { - "operationId": "post__phrases_43", + "operationId": "post__phrases_46", "summary": "POST /phrases", "security": [ { @@ -2055,7 +2213,7 @@ }, "/phrases/{id}": { "patch": { - "operationId": "patch__phrases__id__44", + "operationId": "patch__phrases__id__47", "summary": "PATCH /phrases/{id}", "security": [ { @@ -2114,7 +2272,7 @@ } }, "delete": { - "operationId": "delete__phrases__id__45", + "operationId": "delete__phrases__id__48", "summary": "DELETE /phrases/{id}", "security": [ { @@ -2157,7 +2315,7 @@ }, "/classes/{id}/tools/countdown": { "post": { - "operationId": "post__classes__id__tools_countdown_46", + "operationId": "post__classes__id__tools_countdown_49", "summary": "POST /classes/{id}/tools/countdown", "security": [ { @@ -2218,7 +2376,7 @@ }, "/classes/{id}/tools/rollcall": { "post": { - "operationId": "post__classes__id__tools_rollcall_47", + "operationId": "post__classes__id__tools_rollcall_50", "summary": "POST /classes/{id}/tools/rollcall", "security": [ { @@ -2282,7 +2440,7 @@ }, "/classes/{id}/tools/stop": { "post": { - "operationId": "post__classes__id__tools_stop_48", + "operationId": "post__classes__id__tools_stop_51", "summary": "POST /classes/{id}/tools/stop", "security": [ { @@ -2325,7 +2483,7 @@ }, "/audio": { "post": { - "operationId": "post__audio_49", + "operationId": "post__audio_52", "summary": "POST /audio", "security": [ { @@ -2377,7 +2535,7 @@ }, "/admin/overview": { "get": { - "operationId": "get__admin_overview_50", + "operationId": "get__admin_overview_53", "summary": "GET /admin/overview", "security": [ { @@ -2410,7 +2568,7 @@ }, "/admin/activation-codes": { "get": { - "operationId": "get__admin_activation_codes_51", + "operationId": "get__admin_activation_codes_54", "summary": "GET /admin/activation-codes", "security": [ { @@ -2478,7 +2636,7 @@ } }, "post": { - "operationId": "post__admin_activation_codes_52", + "operationId": "post__admin_activation_codes_55", "summary": "POST /admin/activation-codes", "security": [ { @@ -2552,7 +2710,7 @@ }, "/admin/activation-codes/{code}": { "patch": { - "operationId": "patch__admin_activation_codes__code__53", + "operationId": "patch__admin_activation_codes__code__56", "summary": "PATCH /admin/activation-codes/{code}", "security": [ { @@ -2623,7 +2781,7 @@ }, "/admin/activation-codes/delete": { "post": { - "operationId": "post__admin_activation_codes_delete_54", + "operationId": "post__admin_activation_codes_delete_57", "summary": "POST /admin/activation-codes/delete", "security": [ { @@ -2687,7 +2845,7 @@ }, "/admin/schools": { "get": { - "operationId": "get__admin_schools_55", + "operationId": "get__admin_schools_58", "summary": "GET /admin/schools", "security": [ { @@ -2721,7 +2879,7 @@ } }, "post": { - "operationId": "post__admin_schools_56", + "operationId": "post__admin_schools_59", "summary": "POST /admin/schools", "security": [ { @@ -2772,7 +2930,7 @@ }, "/admin/schools/{id}": { "patch": { - "operationId": "patch__admin_schools__id__57", + "operationId": "patch__admin_schools__id__60", "summary": "PATCH /admin/schools/{id}", "security": [ { @@ -2831,7 +2989,7 @@ } }, "delete": { - "operationId": "delete__admin_schools__id__58", + "operationId": "delete__admin_schools__id__61", "summary": "DELETE /admin/schools/{id}", "security": [ { @@ -2874,7 +3032,7 @@ }, "/admin/users": { "get": { - "operationId": "get__admin_users_59", + "operationId": "get__admin_users_62", "summary": "GET /admin/users", "security": [ { @@ -2908,7 +3066,7 @@ } }, "post": { - "operationId": "post__admin_users_60", + "operationId": "post__admin_users_63", "summary": "POST /admin/users", "security": [ { @@ -2970,7 +3128,7 @@ }, "/admin/users/{id}": { "patch": { - "operationId": "patch__admin_users__id__61", + "operationId": "patch__admin_users__id__64", "summary": "PATCH /admin/users/{id}", "security": [ { @@ -3039,7 +3197,7 @@ } }, "delete": { - "operationId": "delete__admin_users__id__62", + "operationId": "delete__admin_users__id__65", "summary": "DELETE /admin/users/{id}", "security": [ { @@ -3082,7 +3240,7 @@ }, "/admin/users/{id}/unlock-login": { "post": { - "operationId": "post__admin_users__id__unlock_login_63", + "operationId": "post__admin_users__id__unlock_login_66", "summary": "POST /admin/users/{id}/unlock-login", "security": [ { @@ -3125,7 +3283,7 @@ }, "/admin/classes": { "get": { - "operationId": "get__admin_classes_64", + "operationId": "get__admin_classes_67", "summary": "GET /admin/classes", "security": [ { @@ -3159,7 +3317,7 @@ } }, "post": { - "operationId": "post__admin_classes_65", + "operationId": "post__admin_classes_68", "summary": "POST /admin/classes", "security": [ { @@ -3225,7 +3383,7 @@ }, "/admin/classes/{id}": { "patch": { - "operationId": "patch__admin_classes__id__66", + "operationId": "patch__admin_classes__id__69", "summary": "PATCH /admin/classes/{id}", "security": [ { @@ -3294,7 +3452,7 @@ } }, "delete": { - "operationId": "delete__admin_classes__id__67", + "operationId": "delete__admin_classes__id__70", "summary": "DELETE /admin/classes/{id}", "security": [ { @@ -3337,7 +3495,7 @@ }, "/admin/devices": { "get": { - "operationId": "get__admin_devices_68", + "operationId": "get__admin_devices_71", "summary": "GET /admin/devices", "security": [ { @@ -3373,7 +3531,7 @@ }, "/admin/messages": { "get": { - "operationId": "get__admin_messages_69", + "operationId": "get__admin_messages_72", "summary": "GET /admin/messages", "security": [ { @@ -3440,7 +3598,7 @@ }, "/admin/messages/{id}": { "delete": { - "operationId": "delete__admin_messages__id__70", + "operationId": "delete__admin_messages__id__73", "summary": "DELETE /admin/messages/{id}", "security": [ { @@ -3483,7 +3641,7 @@ }, "/admin/audit-logs": { "get": { - "operationId": "get__admin_audit_logs_71", + "operationId": "get__admin_audit_logs_74", "summary": "GET /admin/audit-logs", "security": [ { @@ -3816,6 +3974,75 @@ "sort" ] }, + "StudentCall": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "class_id": { + "type": "string" + }, + "class_name": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "device_name": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "read_at": { + "type": "string" + }, + "read_by": { + "type": "string" + }, + "read_by_name": { + "type": "string" + } + }, + "required": [ + "id", + "class_id", + "class_name", + "device_id", + "device_name", + "created_at", + "read_at", + "read_by", + "read_by_name" + ] + }, + "StudentCallList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StudentCall" + } + }, + "unread_count": { + "type": "integer" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "unread_count", + "next_cursor" + ] + }, "Homework": { "type": "object", "properties": { diff --git a/api/ws/frame.schema.json b/api/ws/frame.schema.json index 4f3f5ea..f8279f4 100644 --- a/api/ws/frame.schema.json +++ b/api/ws/frame.schema.json @@ -740,6 +740,50 @@ "t", "d" ] + }, + { + "type": "object", + "properties": { + "t": { + "const": "student_call" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "ts": { + "type": "integer" + }, + "d": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "class_id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "created", + "read" + ] + } + }, + "required": [ + "id", + "class_id", + "kind" + ] + } + }, + "required": [ + "t", + "d" + ] } ] } diff --git a/deploy/.env.example b/deploy/.env.example index c112b0f..5511347 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,5 +1,5 @@ DOMAIN=classding.example.com -CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.3.4 +CLASSDING_IMAGE=ghcr.io/alumos/classding-server:v0.3.5 # openssl rand -hex 32 JWT_SECRET= # 仅首次部署设置;创建成功后清空用户名/密码并重新创建容器 diff --git a/deploy/compose.1panel.yaml b/deploy/compose.1panel.yaml index 5c1115b..669e3b0 100644 --- a/deploy/compose.1panel.yaml +++ b/deploy/compose.1panel.yaml @@ -1,7 +1,7 @@ # 粘贴到 1Panel 的编排编辑器。首次部署请填写两项密码;升级保留原值和原编排名称。 services: classding: - image: ghcr.io/alumos/classding-server:0.3.4 + image: ghcr.io/alumos/classding-server:0.3.5 platform: linux/amd64 restart: unless-stopped ports: diff --git a/deploy/compose.yaml b/deploy/compose.yaml index dc08eda..1269a89 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,6 +1,6 @@ services: server: - image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.3.4} + image: ${CLASSDING_IMAGE:-ghcr.io/alumos/classding-server:v0.3.5} restart: unless-stopped environment: DATA_DIR: /data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7443d2..385b817 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ | `routes.go` | 全部路由,一行一个接口,标明访问级别与读写 | | `app.go` | 启动、旧数据升级、限流、审计、会话撤销 | | `auth.go`、`security.go`、`validate.go` | 登录与刷新会话、令牌与密码哈希、输入校验 | -| `classes.go`、`devices.go`、`messages.go`、`homework.go`、`audio.go`、`tools.go` | 各业务领域 | +| `classes.go`、`devices.go`、`messages.go`、`homework.go`、`student_calls.go`、`audio.go`、`tools.go` | 各业务领域 | | `admin.go`、`settings.go`、`backup.go` | 管理后台、站点设置、备份与恢复 | | `realtime.go`、`hub.go` | WebSocket 协议与连接池 | | `maintenance.go` | 定时清理过期会话、消息和录音 | diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index a94226c..df28ae4 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -14,7 +14,7 @@ - AuthResponse: {access_token,user};refresh token 放 HttpOnly Cookie。 ## REST -GET config => {speech_enabled:false,registration_enabled:true,version:'0.3.4'} +GET config => {speech_enabled:false,registration_enabled:true,version:'0.3.5'} POST auth/register {username,password,display_name} => AuthResponse POST auth/login {username,password} => AuthResponse POST auth/refresh => AuthResponse;POST auth/logout => {ok:true} @@ -58,6 +58,14 @@ GET admin/devices => Device[];GET admin/messages => {items:Message[],next_curs 作业列表和快照均包含隐藏作业,设备只展示 `visible=true` 的项目。每班最多 12 条,科目 1–24 字、内容 1–300 字;作者和班主任可以修改或删除,其他协同教师仅可查看他人的作业。班级成员均可发布自己的作业。更改在持久化成功后推送给该班设备及教师,教师可据此刷新列表。设备断线后由 welcome 补全快照,并可定期调用 device/homeworks 兜底;客户端应忽略修订号低于已采用快照的响应。 +## 学生呼叫 + +- `POST /device/student-calls {request_id}` 使用设备 Bearer 认证,按设备当前绑定班级创建呼叫;不接受客户端指定班级、教师或学生身份。`request_id` 为 1–128 个 ASCII 字母、数字或 `_.:-`;同设备同班级重试同一 ID 返回原记录且不重复推送,冲突返回 409。同班新呼叫间隔 30 秒,限流返回 429 与 `Retry-After`。 +- `GET /student-calls?class_id=&limit=&cursor=` 只返回当前教师已加入班级的记录,管理员角色不绕过成员检查。`limit` 默认 100、范围 1–100;响应 `{items:StudentCall[],unread_count,next_cursor:string|null}`。按创建时间和 ID 倒序稳定翻页,未读数量不受分页影响;指定班级时数量仅统计该班。 +- `StudentCall` 包含 `id,class_id,class_name,device_id,device_name,created_at,read_at,read_by,read_by_name`;最后三个字段未读时为空字符串。班级与设备名称为记录快照,无学生账号或自由文本。 +- `POST /student-calls/:id/read` 明确标记共享已读;首位处理老师与时间保持不变。仅浏览或打开页面不会标记已读。记录及共享状态写入 SQLite 并包含在备份中,旧备份缺少该集合仍可恢复。 +- 教师 WS 帧 `student_call d:{id,class_id,kind:created|read}` 在持久化提交后发给当前班级成员;网页在线时提示并刷新红色数量,断线重连/定时查询补齐。不是 Web Push,不承诺锁屏或关闭浏览器后提醒。 + ## 前端共享包 - `@classding/ui`:shadcn/ui 组件(`components/ui/*`)、项目自有组件(`components/classding`)、站点设置、`lib/delivery`(投递状态的统一文案与配色)、`notice`(教室通知画面,大屏与教师端预览共用,样式为 `notice.css`)、`version`(读取根目录 `package.json` 的版本号)。 - 大屏客户端的按钮、输入框等轻量控件位于 `screen/frontend/src/ui.tsx` 与 `ui.css`,不进入共享包。 diff --git a/docs/LOCAL_TESTING.md b/docs/LOCAL_TESTING.md index 2da080c..4101db5 100644 --- a/docs/LOCAL_TESTING.md +++ b/docs/LOCAL_TESTING.md @@ -24,12 +24,22 @@ 教师开发账号 `teacher`,密码 `ClassDing2026!`。在「我的班级」获取绑定码,在大屏页留空服务地址并输入绑定码。随后发送一条通知,查看大屏及发送记录。浏览器声音可能需要先点击测试播报;录音还需要浏览器和系统允许麦克风访问。 +## 学生呼叫验收(未发布) + +1. 给同一班级加入两位教师,并将 Windows 大屏绑定该班;确认桌面悬浮球出现。点击后取消,教师端不应产生记录;再次点击并确认发送。 +2. 两位教师在线打开工作台时均应看到「学生呼叫」红色未读数量和提醒;进入页面不消除未读。任一教师手动标记已读后,两端同步消除该条未读,记录保留处理老师和时间。 +3. 另一教师关闭页面期间发起呼叫,重新登录后仍可查看未读;重启服务、刷新网页后记录和已读状态保留。未加入班级的账号不得读取或处理该班记录。 +4. 30 秒内新呼叫应提示稍后再试;断网不得显示发送成功,重试同一请求不产生重复记录。解绑后悬浮入口不可发送。 +5. 在真实 Windows 上检查主窗口隐藏/托盘驻留、悬浮球拖动、多屏工作区与 DPI 缩放、确认/取消、发送中防重复、全屏通知期间隐藏及恢复。浏览器兜底入口和交叉编译不能替代这项实机验收。 +6. 手机 320/390px、桌面及 150% 字号检查六项导航和未读徽标,不应溢出或遮挡操作。 + ## 已自动验证与待验证 -- 浏览器验收覆盖电脑、平板和手机宽度的后台登录、图表、激活码生成及搜索、教师导航;电脑和手机另有真实绑定、200 字通知、回执和录音上传,新增 HTTP 会话保持、账号和班级管理、管理员解除登录限制、复制降级和并发刷新,共 26 项流程。 +- 浏览器验收覆盖电脑、平板和手机宽度的后台登录、图表、激活码生成及搜索、教师导航;电脑和手机另有真实绑定、200 字通知、回执和录音上传,新增 HTTP 会话保持、账号和班级管理、管理员解除登录限制、复制降级和并发刷新等流程。 - 录音流程使用浏览器合成音源和真实 MediaRecorder/上传接口。实际麦克风、微信内置浏览器、Safari 和 Windows WebView2 仍需手动验证。 - 大屏消息单元测试覆盖重连、重复消息、撤回、队列持久化、过期消息与无效封装。 -- Go 测试覆盖权限、租户隔离、会话刷新与账号班级管理、可信代理、双班投递与重连、保存失败回滚和静态网页路由。 +- 学生呼叫浏览器回归覆盖双教师真实 HTTP/WS 共享已读、刷新保留、幂等与限流,并覆盖分页、重连、临时断网、旧服务兼容、账号切换与窄屏布局;页面打开不会自动标记已读。 +- Go 测试覆盖权限、租户隔离、会话刷新与账号班级管理、可信代理、双班投递与重连、保存失败回滚和静态网页路由;学生呼叫另覆盖持久化重启、并发首次处理、跨班拒绝、失败无广播、旧备份兼容及失效 WebSocket 会话过滤。 - Windows 可执行文件可以交叉编译;托盘、开机启动、音量恢复、PPT 置顶、4K 最后一排可读性和 NSIS 安装须在目标机器验证。 - Docker 与 Windows 发布工作流在版本标签推送后运行,实际结果以 GitHub Actions 和 Release 附件为准。服务端和 Windows 客户端均以 amd64 为发布目标,无需 QEMU。 diff --git a/docs/releases/v0.3.5.md b/docs/releases/v0.3.5.md new file mode 100644 index 0000000..38d485c --- /dev/null +++ b/docs/releases/v0.3.5.md @@ -0,0 +1,24 @@ +# 课堂叮咚 v0.3.5 + +学生可以在教室大屏的 Windows 桌面上点「呼叫老师」,二次确认后通知本班全部关联老师;老师在工作台「学生呼叫」中查看并共享处理。 + +## 新功能 + +- **桌面呼叫入口**:绑定后 Windows 桌面会显示「呼叫老师」原生悬浮入口,不占用任务栏。平时不抢焦点,点击后展开二次确认;取消或按 Escape 不发送,默认焦点在取消按钮。拖动可移到其他显示器,松手停在最近显示器工作区内并避开任务栏,按显示器缩放比例调整大小。 +- **呼叫本班全部老师**:确认后由设备身份直接发送,不需要账号,也不会指定班级、老师或学生姓名。本班所有关联老师都会收到这条呼叫记录。 +- **教师端「学生呼叫」**:教师工作台侧栏在「发通知」下方新增入口,未处理时显示红色数量。打开页面时提供网页在线提醒;断线重连或定时刷新后补齐,记录随服务持久化并包含在全站备份中。 +- **共享已读**:任一位老师明确点开标记后,该条记录对其他老师同步为已处理,并保留处理老师与时间。仅浏览列表或进入页面不会自动标记已读。 +- **防重复呼叫**:同一班级 30 秒内只能发起一次;连续点击时客户端显示剩余等待时间,服务端同时拦截。 + +## 升级 + +Linux amd64 镜像:`ghcr.io/alumos/classding-server:0.3.5`。保留现有编排和数据卷,仅更新镜像后重建。旧数据目录和旧版备份可以直接使用,缺少学生呼叫记录时会按空记录处理。 + +Windows amd64 安装包:`ClassDingScreen-amd64-installer.exe`。**大屏需升级到 v0.3.5** 才会出现桌面「呼叫老师」入口;教师端的学生呼叫页面也需要本版服务端。 + +## 已知限制 + +- 只在教师网页打开时提醒,不使用 Web Push,不承诺锁屏、关闭浏览器或手机后台时的提醒。 +- 呼叫内容是固定提示,不含自由文本、录音或学生账号,也不提供回拨。 +- 桌面悬浮入口的系统行为(多显示器、缩放比例、全屏覆盖、托盘、开机启动)仍需在真实 Windows 设备上验收。 +- 同班 30 秒限流按班级计算,不做跨班级或跨设备合并。 diff --git a/package.json b/package.json index 3863ad6..0d0f050 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "classding", "private": true, - "version": "0.3.4", + "version": "0.3.5", "type": "module", "packageManager": "pnpm@11.25.0", "engines": { @@ -17,7 +17,7 @@ "check:toolchain": "node scripts/check-toolchain.mjs", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run screen/frontend/tests web/apps/admin/src/*.test.ts", + "test": "vitest run screen/frontend/tests web/apps/admin/src/*.test.ts web/apps/teacher/src/*.test.ts", "test:e2e": "playwright test", "api:generate": "node scripts/generate-contract.mjs && pnpm --filter @classding/api-generator generate", "prepare:static": "node scripts/prepare-static.mjs" diff --git a/screen/README.md b/screen/README.md index ba39eec..60bba12 100644 --- a/screen/README.md +++ b/screen/README.md @@ -50,6 +50,16 @@ wails build -platform windows/amd64 -nsis -installscope user 作业包含在全站备份中;站点迁移还会核验作业修订号,拒绝明显落后的备份。换站时仍须按发布说明暂停旧站写入并恢复最终备份。 +## 学生呼叫老师 + +绑定后 Windows 桌面左侧会显示「呼叫老师」原生悬浮入口;主窗口关闭到托盘后仍可使用,不依赖网页中的浮层。拖动可移到其他显示器;松手停在最近显示器工作区内,避开任务栏,按显示器 DPI 调整大小。悬浮入口平时不抢焦点,只有点击展开二次确认时才激活;取消或 Escape 不发送,默认键盘焦点在取消按钮,Tab 可切换到确认按钮。全屏通知、课堂工具展示期间临时隐藏,结束后恢复,不移动或改变已有作业窗。 + +确认后由原有 WebView service 复用设备认证和 loopback 代理发送 `POST /api/v1/device/student-calls`,Go 原生层不保存另一份密钥。发送中禁止重复提交;成功须收到有效的服务器记录才显示「呼叫已发送」。本班所有关联老师在打开的教师网页收到提醒,任一老师标记已读后共享为已处理。服务端按同班 30 秒限流,客户端显示 `Retry-After` 等待时间。网络异常或响应丢失会显示结果未确认,用户主动重试沿用同一个幂等编号;不会离线排队或自动补发,退出程序后不保留未确认编号。 + +离线时保留入口供查看明确错误,确认按钮不可发送;解绑清理入口和未完成请求,再绑定不会接收旧绑定结果。请求超时和 WebView 无响应分别有服务请求/原生看门狗超时提示;客户端待命页另有相同二次确认的备用入口,浏览器预览仅提供备用入口,不会创建原生桌面窗口。 + +待 Windows 实机验收:主窗口关闭/静默托盘状态呼叫、鼠标和触屏点击/拖动边界、取消不发、键盘焦点与 Escape/Tab/Enter、断网及 401/403/429 错误、重试不重复、解绑中止、混合 DPI/负坐标多屏/任务栏工作区变化、全屏通知/课堂工具与作业窗恢复、退出清理 GDI 和窗口资源。交叉编译、Go 测试及浏览器测试不能替代上述实机检查。 + ## 当前未完成 渲染进程守护与日志轮转、自动下载更新、字体子集和语音包检测尚未实现。托盘、静默启动、多显示器及 Wails 程序的 Windows/NSIS 实机测试尚未完成;不得以浏览器预览或交叉编译成功代替实机验收。 diff --git a/screen/app.go b/screen/app.go index 82b584a..ffa2f83 100644 --- a/screen/app.go +++ b/screen/app.go @@ -42,7 +42,8 @@ type App struct { audioMu sync.Mutex original *volumeSnapshot - homework homeworkState + homework homeworkState + studentCall studentCallState } func NewApp() (*App, error) { @@ -92,6 +93,9 @@ func (a *App) startup(ctx context.Context) { } } if a.initErr == nil { + if err := a.startStudentCall(); err != nil { + log.Printf("学生呼叫悬浮入口初始化失败: %v", err) + } if err := a.RestoreAudio(); err != nil { log.Printf("恢复上次音量失败: %v", err) } @@ -159,6 +163,7 @@ func (a *App) trayUnavailable() { } func (a *App) shutdown(_ context.Context) { + a.closeStudentCall() a.closeHomework() a.windowMu.Lock() tray := a.tray @@ -192,6 +197,7 @@ func (a *App) SetPresentationActive(active bool) error { } <-a.initDone a.suspendHomework(active) + a.suspendStudentCall(active) if !active { runtime.WindowUnfullscreen(a.ctx) runtime.WindowSetAlwaysOnTop(a.ctx, false) diff --git a/screen/frontend/package.json b/screen/frontend/package.json index ca17bff..9d0af0f 100644 --- a/screen/frontend/package.json +++ b/screen/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@classding/screen", - "version": "0.3.4", + "version": "0.3.5", "private": true, "type": "module", "scripts": { diff --git a/screen/frontend/src/main.tsx b/screen/frontend/src/main.tsx index b2fdc05..50ebe59 100644 --- a/screen/frontend/src/main.tsx +++ b/screen/frontend/src/main.tsx @@ -1,6 +1,14 @@ import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { createRoot } from 'react-dom/client'; import { NoticeScreen } from '@classding/ui/notice'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@classding/ui/components/ui/dialog'; import { Badge, Brand, Button, Card, Input, Switch } from './ui'; import { ArrowRight, @@ -34,6 +42,7 @@ void service.init(); function App() { const state = useSyncExternalStore(service.subscribe, service.getSnapshot); const [code, setCode] = useState(''); + const [callOpen, setCallOpen] = useState(false); const [deviceName, setDeviceName] = useState(''); const [server, setServer] = useState(''); const [advanced, setAdvanced] = useState(false); @@ -363,6 +372,45 @@ function App() {
+ +

需要老师来教室?

+

向本班所有关联老师发送呼叫。Windows 关闭主窗口后仍可使用桌面悬浮入口。

+ + {state.studentCallMessage &&

{state.studentCallMessage}

} +
+ + + + 确认呼叫老师? + + {state.className}:将通知本班所有关联老师。取消不会发送。 + + + {state.connection !== 'online' && ( +

连接中断,呼叫未发送,请恢复网络后重试。

+ )} + {state.studentCallMessage &&

{state.studentCallMessage}

} + + + + +
+
diff --git a/screen/frontend/src/native.ts b/screen/frontend/src/native.ts index 13c3b1b..7570d73 100644 --- a/screen/frontend/src/native.ts +++ b/screen/frontend/src/native.ts @@ -13,6 +13,8 @@ export interface HomeworkSettings { preferences: Record; } export interface NativeApp { + UpdateStudentCallStatus(bound: boolean, online: boolean, className: string, binding: number): Promise; + CompleteStudentCall(id: string, success: boolean, message: string): Promise; UpdateHomeworks(value: string): Promise; GetHomeworkSettings(): Promise; SetHomeworkPreference(id: string, action: string): Promise; @@ -42,7 +44,8 @@ declare global { }; } } -export const native = (): NativeApp | undefined => window.go?.main?.App; +export const native = (): NativeApp | undefined => + typeof window === 'undefined' ? undefined : window.go?.main?.App; const KEY = 'classding.screen.state.v1'; export async function loadState(): Promise { return native() ? await native()!.LoadState() : localStorage.getItem(KEY) || ''; diff --git a/screen/frontend/src/service.ts b/screen/frontend/src/service.ts index ba045bc..173d1fe 100644 --- a/screen/frontend/src/service.ts +++ b/screen/frontend/src/service.ts @@ -1,6 +1,6 @@ import type { components, paths } from '@classding/api/types'; import type { AckStatus, Notice, WSFrame } from '@classding/api/ws'; -import { configureServer, getLocalAddresses, loadState, normalizeServer, saveState } from './native'; +import { configureServer, getLocalAddresses, loadState, normalizeServer, saveState, native } from './native'; import { readHomeworks, type Homework } from './homework'; import { playChime, playNotice } from './audio'; import { APP_VERSION } from '@classding/ui/version'; @@ -53,6 +53,8 @@ export type Tool = | { name: 'rollcall'; names: string[]; selected: string; ends: number } | null; export interface Snapshot { + studentCallSending: boolean; + studentCallMessage: string; homeworks: Homework[]; ready: boolean; busy: boolean; @@ -163,6 +165,8 @@ const remoteConfig = (value: unknown): RemoteConfig | undefined => { export class ScreenService { private state = fresh(); private view: Snapshot = { + studentCallSending: false, + studentCallMessage: '', homeworks: [], ready: false, busy: false, @@ -198,6 +202,12 @@ export class ScreenService { private suppressStarts = false; private generation = 0; private disposed = false; + private bindingEpoch = 0; + private callController?: AbortController; + private callID = ''; + private callOff?: () => void; + private callNativeQueue: Promise = Promise.resolve(); + private callNativeKey = ''; subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { @@ -219,6 +229,7 @@ export class ScreenService { pending: Math.max(0, this.state.queue.length - (current ? 1 : 0)), }; this.listeners.forEach((fn) => fn()); + this.syncStudentCall(); } private mutate(change: () => void): Promise { const operation = this.ops.then(async () => { @@ -257,6 +268,19 @@ export class ScreenService { } } async init() { + this.callOff = window.runtime?.EventsOnMultiple( + 'student-call:send', + (id, binding) => { + if (typeof id !== 'string' || binding !== this.bindingEpoch) return; + void this.sendStudentCall(id) + .then( + () => native()?.CompleteStudentCall(id, true, '呼叫已发送,本班老师打开网页即可收到提醒'), + (error) => native()?.CompleteStudentCall(id, false, errorText(error)), + ) + .catch(() => {}); + }, + -1, + ); try { const raw = await loadState(); if (raw) { @@ -331,6 +355,7 @@ export class ScreenService { ) throw new Error('服务器返回的绑定信息不完整'); serverAccepted = true; + this.resetStudentCall(); await this.mutate(() => { this.state = { ...fresh(), @@ -899,6 +924,96 @@ export class ScreenService { } return `${this.base}${url.startsWith('/') ? '' : '/'}${url}`; } + private syncStudentCall() { + if (this.disposed || !native()) return; + const { bound, connection, className } = this.view; + const online = connection === 'online'; + const epoch = this.bindingEpoch; + const key = JSON.stringify([bound, online, className, epoch]); + if (key === this.callNativeKey) return; + this.callNativeKey = key; + this.callNativeQueue = this.callNativeQueue + .catch(() => {}) + .then(async () => { + if (!this.disposed) await native()?.UpdateStudentCallStatus(bound, online, className, epoch); + }) + .catch((error) => { + this.emit({ error: `学生呼叫入口更新失败:${errorText(error)}` }); + }); + } + private resetStudentCall() { + this.bindingEpoch++; + this.callController?.abort(); + this.callController = undefined; + this.callID = ''; + this.emit({ studentCallSending: false, studentCallMessage: '' }); + } + // Retry an uncertain request with its original id, never queue calls offline. + async sendStudentCall(requestID?: string): Promise { + if (this.disposed || !this.state.token) throw new Error('设备未绑定,请重新绑定后呼叫'); + if (this.view.studentCallSending) throw new Error('呼叫正在发送,请勿重复点击'); + if (this.view.connection !== 'online') throw new Error('连接中断,呼叫未发送。请恢复网络后重试'); + const id = this.callID || requestID || crypto.randomUUID(); + if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id)) throw new Error('呼叫编号无效'); + this.callID = id; + const epoch = this.bindingEpoch; + const controller = new AbortController(); + this.callController = controller; + this.emit({ studentCallSending: true, studentCallMessage: '正在发送,请稍候…' }); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await this.request('/api/v1/device/student-calls', { + method: 'POST', + headers: { Authorization: `Bearer ${this.state.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: id }), + signal: controller.signal, + }); + const data = (await response.json().catch(() => ({}))) as Record; + if (epoch !== this.bindingEpoch || this.disposed) throw new Error('设备绑定已变化,呼叫结果不再适用'); + if (!response.ok) { + if (response.status === 401) { + await this.unbind(); + throw new Error('设备已失效或被解绑,请重新绑定'); + } + if (response.status === 403) throw new Error('设备授权不可用,请联系学校管理员'); + if (response.status === 429) { + this.callID = ''; + const seconds = response.headers?.get('Retry-After'); + throw new Error( + seconds && /^\d+$/.test(seconds) + ? '本班刚刚发起过呼叫,请在 ' + seconds + ' 秒后重试' + : '本班刚刚发起过呼叫,请稍后重试', + ); + } + if (response.status === 409) this.callID = ''; + throw new Error( + typeof data.message === 'string' ? data.message : '发送失败(' + response.status + '),请重试', + ); + } + if ( + typeof data.id !== 'string' || + !data.id || + data.class_id !== this.state.classId || + data.device_id !== this.state.deviceId || + typeof data.created_at !== 'string' || + !Number.isFinite(Date.parse(data.created_at)) + ) + throw new Error('服务器未确认有效呼叫,结果未知,请重试(不会重复创建同一呼叫)'); + this.callID = ''; + this.emit({ studentCallMessage: '呼叫已发送,本班老师打开网页即可收到提醒' }); + } catch (error) { + const message = + abortError(error) || error instanceof TypeError + ? '网络异常,发送结果未确认,请重试(不会重复创建同一呼叫)' + : errorText(error); + if (epoch === this.bindingEpoch && !this.disposed) this.emit({ studentCallMessage: message }); + throw new Error(message); + } finally { + clearTimeout(timeout); + if (this.callController === controller) this.callController = undefined; + if (epoch === this.bindingEpoch && !this.disposed) this.emit({ studentCallSending: false }); + } + } async dismiss() { const id = this.view.current?.message_id; if (!id) return; @@ -914,6 +1029,7 @@ export class ScreenService { } // Stops playback, timers and the socket; the next connect starts fresh. private halt() { + this.resetStudentCall(); this.generation++; this.active?.abort(); clearTimeout(this.retry); @@ -946,6 +1062,8 @@ export class ScreenService { } dispose() { this.disposed = true; + this.callOff?.(); + this.callOff = undefined; this.halt(); } } diff --git a/screen/frontend/src/ui.css b/screen/frontend/src/ui.css index cb0e820..519777a 100644 --- a/screen/frontend/src/ui.css +++ b/screen/frontend/src/ui.css @@ -56,6 +56,65 @@ button:disabled { cursor: not-allowed; opacity: 0.5; } + +/* Base UI dialog primitives are shared with apps that compile Tailwind utilities. + The screen bundle intentionally keeps its own small modal layout so the native + fallback confirmation remains usable when the main window is still visible. */ +[data-slot='dialog-overlay'] { + position: fixed; + inset: 0; + z-index: 50; + isolation: isolate; + background: rgb(38 55 46 / 18%); +} + +[data-slot='dialog-content'] { + position: fixed; + top: 50%; + left: 50%; + z-index: 51; + display: grid; + width: calc(100% - 2rem); + max-width: 24rem; + gap: 1rem; + padding: 1rem; + transform: translate(-50%, -50%); + border: 1px solid var(--cd-border); + border-radius: 0.75rem; + background: var(--cd-panel); + color: var(--cd-text); + box-shadow: 0 18px 48px rgb(38 55 46 / 20%); + outline: none; +} + +[data-slot='dialog-content'] [data-slot='dialog-header'] { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +[data-slot='dialog-content'] [data-slot='dialog-footer'] { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin: 0 -1rem -1rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--cd-border); + border-radius: 0 0 0.75rem 0.75rem; + background: var(--cd-soft); +} + +[data-slot='dialog-content'] > [data-slot='dialog-close'] { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +@media (min-width: 640px) { + [data-slot='dialog-content'] { + max-width: 24rem; + } +} a { color: inherit; text-decoration: none; diff --git a/screen/frontend/tests/native-student-call.test.ts b/screen/frontend/tests/native-student-call.test.ts new file mode 100644 index 0000000..9047641 --- /dev/null +++ b/screen/frontend/tests/native-student-call.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + disk: JSON.stringify({ + version: 1, + token: 'student-call-bridge-token', + deviceId: 'd1', + classId: 'c1', + deviceName: '教室', + className: '三年二班', + server: 'http://localhost:18080', + volume: 70, + lastSeq: 0, + queue: [], + ledger: {}, + }), + configure: vi.fn(async (server: string) => server), +})); + +vi.mock('../src/native', async (importOriginal) => ({ + ...(await importOriginal()), + loadState: async () => mocks.disk, + saveState: async (value: string) => { + mocks.disk = value; + }, + configureServer: mocks.configure, +})); + +import { ScreenService } from '../src/service'; + +class Socket { + static OPEN = 1; + static instances: Socket[] = []; + readyState = 1; + onopen?: () => void; + onmessage?: (event: { data: string }) => void; + onclose?: () => void; + onerror?: () => void; + constructor(public url: string) { + Socket.instances.push(this); + } + send(_text: string) {} + close() { + this.onclose?.(); + } + frame(value: object) { + this.onmessage?.({ data: JSON.stringify(value) }); + } +} + +const welcome = { t: 'welcome', d: { class: { id: 'c1', name: '三年二班' }, volume: 70 } }; +let services: ScreenService[] = []; +let eventHandler: ((...args: unknown[]) => void) | undefined; +let eventOff: ReturnType; +let app: { + UpdateStudentCallStatus: ReturnType; + CompleteStudentCall: ReturnType; +}; + +beforeEach(() => { + mocks.disk = JSON.stringify({ + version: 1, + token: 'student-call-bridge-token', + deviceId: 'd1', + classId: 'c1', + deviceName: '教室', + className: '三年二班', + server: 'http://localhost:18080', + volume: 70, + lastSeq: 0, + queue: [], + ledger: {}, + }); + mocks.configure.mockClear(); + Socket.instances = []; + services = []; + eventHandler = undefined; + eventOff = vi.fn(); + app = { + UpdateStudentCallStatus: vi.fn(async () => {}), + CompleteStudentCall: vi.fn(async () => {}), + }; + vi.stubGlobal('window', { + location: { origin: 'http://localhost:5175', href: 'http://localhost:5175/' }, + go: { main: { App: app } }, + runtime: { + EventsOnMultiple: vi.fn((_name: string, callback: (...args: unknown[]) => void) => { + eventHandler = callback; + return eventOff; + }), + }, + }); + vi.stubGlobal('WebSocket', Socket); +}); + +afterEach(() => { + services.forEach((service) => service.dispose()); + vi.unstubAllGlobals(); +}); + +async function online() { + const service = new ScreenService(); + services.push(service); + await service.init(); + Socket.instances.at(-1)!.frame(welcome); + await vi.waitFor(() => expect(service.getSnapshot().connection).toBe('online')); + await vi.waitFor(() => expect(app.UpdateStudentCallStatus).toHaveBeenCalled()); + return service; +} + +function response(data: unknown, status = 200) { + return { ok: status < 400, status, json: async () => data, headers: new Headers() } as Response; +} + +describe('学生呼叫原生桥接', () => { + it('注册事件、拒绝旧绑定 epoch,并回传发送结果', async () => { + const service = await online(); + expect(eventHandler).toBeDefined(); + expect(eventOff).not.toHaveBeenCalled(); + + const request = vi.fn().mockResolvedValue({ + ...response({ id: 'call1', class_id: 'c1', device_id: 'd1', created_at: '2026-11-13T03:00:00Z' }), + }); + vi.stubGlobal('fetch', request); + + eventHandler!('stale-id', 99); + await Promise.resolve(); + expect(request).not.toHaveBeenCalled(); + expect(app.CompleteStudentCall).not.toHaveBeenCalled(); + + eventHandler!('native-call-id', 0); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => + expect(app.CompleteStudentCall).toHaveBeenCalledWith( + 'native-call-id', + true, + '呼叫已发送,本班老师打开网页即可收到提醒', + ), + ); + expect(JSON.parse(request.mock.calls[0][1].body).request_id).toBe('native-call-id'); + }); + + it('解绑使旧epoch事件失效,dispose移除监听', async () => { + const service = await online(); + const request = vi.fn(); + vi.stubGlobal('fetch', request); + const callback = eventHandler!; + await service.unbind(); + callback('old-binding', 0); + await Promise.resolve(); + expect(request).not.toHaveBeenCalled(); + expect(eventOff).not.toHaveBeenCalled(); + service.dispose(); + expect(eventOff).toHaveBeenCalledTimes(1); + callback('after-dispose', 0); + await Promise.resolve(); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/screen/frontend/tests/service.test.ts b/screen/frontend/tests/service.test.ts index 8b28ef2..69e6037 100644 --- a/screen/frontend/tests/service.test.ts +++ b/screen/frontend/tests/service.test.ts @@ -97,6 +97,138 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('学生呼叫', () => { + const result = { id: 'call1', class_id: 'c1', device_id: 'd1', created_at: '2026-11-13T03:00:00Z' }; + async function online() { + mocks.disk = JSON.stringify({ + version: 1, + token: 'student-call-test-token', + deviceId: 'd1', + classId: 'c1', + deviceName: '教室', + className: '三年二班', + volume: 70, + lastSeq: 0, + queue: [], + ledger: {}, + }); + const service = makeService(); + await service.init(); + socket().frame(welcome); + await vi.waitFor(() => expect(service.getSnapshot().connection).toBe('online')); + return service; + } + function response(data: unknown, status = 200, retry?: string) { + return { + ok: status < 400, + status, + json: async () => data, + headers: new Headers(retry ? { 'Retry-After': retry } : {}), + } as Response; + } + it('未绑定和离线不发送请求', async () => { + const request = vi.fn(); + vi.stubGlobal('fetch', request); + const service = makeService(); + await service.init(); + await expect(service.sendStudentCall()).rejects.toThrow('未绑定'); + const bound = await online(); + socket().close(); + await expect(bound.sendStudentCall()).rejects.toThrow('连接中断'); + expect(request.mock.calls.filter(([path]) => String(path).includes('student-calls'))).toHaveLength(0); + }); + it('复用设备认证并阻止发送中重复,成功必须收到有效记录', async () => { + const service = await online(); + let resolve!: (value: Response) => void; + const request = vi.fn( + (_path: string, _init?: RequestInit) => + new Promise((done) => { + resolve = done; + }), + ); + vi.stubGlobal('fetch', request); + const sending = service.sendStudentCall('native-id'); + await expect(service.sendStudentCall()).rejects.toThrow('正在发送'); + expect(request).toHaveBeenCalledWith( + '/api/v1/device/student-calls', + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer student-call-test-token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: 'native-id' }), + }), + ); + resolve(response(result)); + await sending; + expect(service.getSnapshot()).toMatchObject({ + studentCallSending: false, + studentCallMessage: expect.stringContaining('已发送'), + }); + }); + it('不确定失败保留幂等编号,跨备用入口重试不创建新请求', async () => { + const service = await online(); + const request = vi + .fn() + .mockRejectedValueOnce(new TypeError('offline')) + .mockResolvedValue(response(result)); + vi.stubGlobal('fetch', request); + await expect(service.sendStudentCall('original-id')).rejects.toThrow('未确认'); + await service.sendStudentCall('another-native-id'); + expect(request.mock.calls.map(([, init]) => JSON.parse(init.body).request_id)).toEqual([ + 'original-id', + 'original-id', + ]); + }); + it('限流显示等待秒数,不假成功;无效成功响应可重试', async () => { + const service = await online(); + const request = vi + .fn() + .mockResolvedValueOnce(response({}, 429, '27')) + .mockResolvedValueOnce(response({})) + .mockResolvedValue(response(result)); + vi.stubGlobal('fetch', request); + await expect(service.sendStudentCall('rate-id')).rejects.toThrow('27 秒'); + await expect(service.sendStudentCall('new-id')).rejects.toThrow('未确认有效呼叫'); + await service.sendStudentCall('ignore-id'); + expect(request.mock.calls.map(([, init]) => JSON.parse(init.body).request_id)).toEqual([ + 'rate-id', + 'new-id', + 'new-id', + ]); + }); + it('解绑中止请求并拒绝旧成功写回', async () => { + const service = await online(); + let resolve!: (value: Response) => void; + let signal: AbortSignal | undefined; + vi.stubGlobal( + 'fetch', + vi.fn((_path: string, init: RequestInit) => { + signal = init.signal!; + return new Promise((done) => { + resolve = done; + }); + }), + ); + const pending = service.sendStudentCall('old-binding'); + await service.unbind(); + expect(signal?.aborted).toBe(true); + resolve(response(result)); + await expect(pending).rejects.toThrow('绑定已变化'); + expect(service.getSnapshot()).toMatchObject({ + bound: false, + studentCallSending: false, + studentCallMessage: '', + }); + }); + it('401 清除失效绑定,403 明确显示授权不可用', async () => { + const service = await online(); + const request = vi.fn().mockResolvedValueOnce(response({}, 403)).mockResolvedValue(response({}, 401)); + vi.stubGlobal('fetch', request); + await expect(service.sendStudentCall('license-id')).rejects.toThrow('授权不可用'); + await expect(service.sendStudentCall()).rejects.toThrow('已失效'); + expect(service.getSnapshot().bound).toBe(false); + }); +}); + describe('大屏消息可靠性', () => { it('忽略格式无效的消息封装,并继续接收合法通知', async () => { mocks.disk = JSON.stringify({ diff --git a/screen/student_call.go b/screen/student_call.go new file mode 100644 index 0000000..113469d --- /dev/null +++ b/screen/student_call.go @@ -0,0 +1,181 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// No credentials cross this bridge. The existing WebView service performs the +// authenticated request through its configured loopback proxy, even in the tray. +type studentCallView struct { + Bound, Online, Suspended, Sending bool + ClassName, Message string + Binding, Revision int +} +type studentCallWindow interface { + update(studentCallView) error + close() +} +type studentCallState struct { + mu sync.Mutex + window studentCallWindow + initErr error + view studentCallView + requestID string + binding int + timer *time.Timer + closed bool + emit func(string, int) +} + +func (a *App) startStudentCall() error { + a.studentCall.mu.Lock() + defer a.studentCall.mu.Unlock() + a.studentCall.emit = func(id string, binding int) { + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "student-call:send", id, binding) + } + } + window, err := newStudentCallWindow(a.beginStudentCall) + if err != nil { + a.studentCall.initErr = err + return err + } + a.studentCall.window = window + return window.update(a.studentCall.view) +} + +// UpdateStudentCallStatus receives non-secret binding/connectivity metadata. +func (a *App) UpdateStudentCallStatus(bound, online bool, className string, binding int) error { + if err := a.ready(); err != nil { + return err + } + s := &a.studentCall + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("学生呼叫已关闭") + } + if s.view.Bound != bound || s.view.ClassName != className || s.binding != binding { + if s.timer != nil { + s.timer.Stop() + s.timer = nil + } + s.requestID = "" + s.view.Sending = false + s.view.Message = "" + } + s.binding, s.view.Binding = binding, binding + s.view.Bound, s.view.Online, s.view.ClassName = bound, online, className + return s.renderLocked() +} +func (s *studentCallState) renderLocked() error { + s.view.Revision++ + if s.window != nil { + return s.window.update(s.view) + } + return s.initErr +} +func (a *App) beginStudentCall(binding int) { + s := &a.studentCall + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || s.view.Sending || s.view.Suspended || binding != s.binding { + return + } + if !s.view.Bound { + s.view.Message = "设备未绑定,请打开课堂叮咚重新绑定" + _ = s.renderLocked() + return + } + if !s.view.Online { + s.view.Message = "连接中断,呼叫未发送。请恢复网络后重试" + _ = s.renderLocked() + return + } + if s.requestID == "" { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + s.view.Message = "无法生成呼叫编号,请重试" + _ = s.renderLocked() + return + } + s.requestID = hex.EncodeToString(id[:]) + } + id := s.requestID + s.view.Sending, s.view.Message = true, "正在发送,请稍候…" + _ = s.renderLocked() + // This also covers a stalled/crashed WebView. JS retains the canonical request id. + s.timer = time.AfterFunc(12*time.Second, func() { + a.finishStudentCall(id, false, "发送结果未确认,请重试(不会重复创建同一呼叫)") + }) + if s.emit != nil { + s.emit(id, binding) + } +} +func (a *App) CompleteStudentCall(id string, success bool, message string) { + a.finishStudentCall(id, success, message) +} +func (a *App) finishStudentCall(id string, success bool, message string) { + s := &a.studentCall + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || id == "" || id != s.requestID { + return + } + if s.timer != nil { + s.timer.Stop() + s.timer = nil + } + s.view.Sending = false + s.view.Message = message + // The JS service owns the canonical idempotency key across uncertain retries. + // This id only correlates this native attempt, so stale callbacks cannot finish a retry. + s.requestID = "" + _ = s.renderLocked() +} +func (a *App) suspendStudentCall(active bool) { + s := &a.studentCall + s.mu.Lock() + defer s.mu.Unlock() + s.view.Suspended = active + _ = s.renderLocked() +} +func (a *App) closeStudentCall() { + s := &a.studentCall + s.mu.Lock() + s.closed = true + s.emit = nil + if s.timer != nil { + s.timer.Stop() + s.timer = nil + } + window := s.window + s.window = nil + s.mu.Unlock() + // Never wait for the UI thread while holding the callback/state mutex. + if window != nil { + window.close() + } +} + +// A suspend/unbind can win the state mutex before a native confirmation. +// It must clear the native optimistic spinner even when begin exits without emitting. +func studentCallAcknowledged(previous, next studentCallView, sentRevision int) bool { + return next.Suspended || !next.Bound || next.Binding != previous.Binding || + (next.Revision > sentRevision && (next.Sending || next.Message != "")) +} + +// Geometry is in physical pixels, including negative monitor origins. +func studentCallClamp(x, y, width, height, left, top, right, bottom int) (int, int) { + return max(left, min(x, right-width)), max(top, min(y, bottom-height)) +} +func studentCallDragged(dx, dy, dpi int) bool { + threshold := max(4, 6*dpi/96) + return dx*dx+dy*dy >= threshold*threshold +} diff --git a/screen/student_call_other.go b/screen/student_call_other.go new file mode 100644 index 0000000..73f7ff9 --- /dev/null +++ b/screen/student_call_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package main + +type noopStudentCallWindow struct{} + +func newStudentCallWindow(_ func(int)) (studentCallWindow, error) { + return noopStudentCallWindow{}, nil +} +func (noopStudentCallWindow) update(studentCallView) error { return nil } +func (noopStudentCallWindow) close() {} diff --git a/screen/student_call_test.go b/screen/student_call_test.go new file mode 100644 index 0000000..0604c12 --- /dev/null +++ b/screen/student_call_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "strings" + "sync" + "testing" +) + +type callTestWindow struct { + mu sync.Mutex + latest studentCallView + closed bool +} + +func (w *callTestWindow) update(v studentCallView) error { + w.mu.Lock() + defer w.mu.Unlock() + w.latest = v + return nil +} +func (w *callTestWindow) close() { w.mu.Lock(); defer w.mu.Unlock(); w.closed = true } +func callTestApp(t *testing.T) (*App, *[]string) { + t.Helper() + ready := make(chan struct{}) + close(ready) + ids := []string{} + a := &App{initDone: ready} + a.studentCall.window = &callTestWindow{} + a.studentCall.emit = func(id string, _ int) { ids = append(ids, id) } + t.Cleanup(a.closeStudentCall) + return a, &ids +} +func TestStudentCallGatesAndCompletion(t *testing.T) { + a, ids := callTestApp(t) + a.beginStudentCall(0) + if len(*ids) != 0 || !strings.Contains(a.studentCall.view.Message, "未绑定") { + t.Fatal("unbound call accepted") + } + _ = a.UpdateStudentCallStatus(true, false, "同名班", 1) + a.beginStudentCall(1) + if len(*ids) != 0 || !strings.Contains(a.studentCall.view.Message, "连接中断") { + t.Fatal("offline call accepted") + } + _ = a.UpdateStudentCallStatus(true, true, "同名班", 1) + a.beginStudentCall(0) + if len(*ids) != 0 { + t.Fatal("stale confirmation accepted") + } + a.beginStudentCall(1) + a.beginStudentCall(1) + if len(*ids) != 1 || len((*ids)[0]) != 32 { + t.Fatal("missing duplicate guard or invalid id") + } + id := (*ids)[0] + a.CompleteStudentCall("stale", true, "wrong") + if !a.studentCall.view.Sending { + t.Fatal("stale callback applied") + } + a.CompleteStudentCall(id, false, "失败可见") + if a.studentCall.view.Sending || a.studentCall.view.Message != "失败可见" { + t.Fatal("failure not rendered") + } + a.beginStudentCall(1) + if len(*ids) != 2 || (*ids)[1] == id { + t.Fatal("native attempt correlation id not renewed") + } + a.CompleteStudentCall(id, true, "late") + if !a.studentCall.view.Sending { + t.Fatal("previous attempt completed new retry") + } + a.CompleteStudentCall((*ids)[1], true, "成功可见") + if a.studentCall.view.Sending || a.studentCall.view.Message != "成功可见" { + t.Fatal("success not rendered") + } +} +func TestStudentCallPendingSnapshot(t *testing.T) { + previous := studentCallView{Bound: true, Binding: 1, Revision: 3} + for _, next := range []studentCallView{ + {Bound: true, Binding: 1, Revision: 4, Suspended: true}, + {Bound: false, Binding: 1, Revision: 4}, + {Bound: true, Binding: 2, Revision: 4}, + {Bound: true, Binding: 1, Revision: 4, Sending: true}, + {Bound: true, Binding: 1, Revision: 4, Message: "done"}, + } { + if !studentCallAcknowledged(previous, next, 3) { + t.Fatalf("native pending spinner not cleared by %#v", next) + } + } + if studentCallAcknowledged(previous, previous, 3) { + t.Fatal("stale pre-send snapshot cleared native pending guard") + } +} + +func TestStudentCallRebindSuspendClose(t *testing.T) { + a, ids := callTestApp(t) + _ = a.UpdateStudentCallStatus(true, true, "同名班", 1) + a.suspendStudentCall(true) + a.beginStudentCall(1) + if len(*ids) != 0 { + t.Fatal("presentation accepted call") + } + a.suspendStudentCall(false) + a.beginStudentCall(1) + id := (*ids)[0] + _ = a.UpdateStudentCallStatus(true, true, "同名班", 2) + a.CompleteStudentCall(id, true, "old") + if a.studentCall.view.Sending || a.studentCall.view.Message != "" { + t.Fatal("same-name rebind reused result") + } + a.closeStudentCall() + a.beginStudentCall(2) + if len(*ids) != 1 { + t.Fatal("closed manager emitted") + } + if a.UpdateStudentCallStatus(true, true, "x", 3) == nil { + t.Fatal("closed manager accepted state") + } +} +func TestStudentCallPhysicalGeometry(t *testing.T) { + x, y := studentCallClamp(-2200, 1200, 100, 80, -1920, -200, 0, 1000) + if x != -1920 || y != 920 { + t.Fatalf("negative monitor clamp: %d,%d", x, y) + } + x, y = studentCallClamp(2100, -100, 120, 80, 1920, 0, 3840, 1080) + if x != 2100 || y != 0 { + t.Fatalf("second workarea clamp: %d,%d", x, y) + } + if studentCallDragged(3, 2, 96) || !studentCallDragged(6, 0, 96) || studentCallDragged(8, 0, 192) || !studentCallDragged(12, 0, 192) { + t.Fatal("DPI drag threshold") + } +} diff --git a/screen/student_call_windows.go b/screen/student_call_windows.go new file mode 100644 index 0000000..eaf19a9 --- /dev/null +++ b/screen/student_call_windows.go @@ -0,0 +1,380 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "runtime" + "sync" + "syscall" + "unsafe" +) + +const scUpdate = wmApp + 61 + +var scWindows sync.Map +var scProc = syscall.NewCallback(studentCallWndProc) + +type nativeStudentCallWindow struct { + mu sync.Mutex + pending studentCallView + closed bool + hwnd uintptr + done chan struct{} + onConfirm func(int) + // All fields below are owned by the native UI thread. + view studentCallView + expanded, down, dragged, positioned bool + origin, cursor trayPoint + x, y, width, height, dpi int + confirm, cancel uintptr + awaiting bool + sentRevision int + buttonFont uintptr + fontDPI int +} + +func newStudentCallWindow(onConfirm func(int)) (studentCallWindow, error) { + m := &nativeStudentCallWindow{done: make(chan struct{}), onConfirm: onConfirm, dpi: 96} + ready := make(chan error, 1) + go m.run(ready) + if err := <-ready; err != nil { + return nil, err + } + return m, nil +} +func (m *nativeStudentCallWindow) run(ready chan error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer close(m.done) + name := syscall.StringToUTF16Ptr("ClassDingStudentCall") + title := syscall.StringToUTF16Ptr("呼叫老师 · 课堂叮咚") + instance, _, _ := getModuleHandle.Call(0) + arrow, _, _ := hwLoadCursor.Call(0, 32512) + class := trayWindowClass{size: uint32(unsafe.Sizeof(trayWindowClass{})), windowProc: scProc, instance: instance, cursor: arrow, className: uintptr(unsafe.Pointer(name))} + if atom, _, err := registerClassEx.Call(uintptr(unsafe.Pointer(&class))); atom == 0 { + ready <- fmt.Errorf("无法注册呼叫窗口:%w", err) + return + } + defer unregisterClass.Call(uintptr(unsafe.Pointer(name)), instance) + hwnd, _, err := createWindowEx.Call(0x80|0x08000000, uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(title)), 0x80000000, 0, 0, 80, 64, 0, 0, instance, 0) // TOOLWINDOW | NOACTIVATE / POPUP + if hwnd == 0 { + ready <- fmt.Errorf("无法创建呼叫窗口:%w", err) + return + } + m.hwnd = hwnd + scWindows.Store(hwnd, m) + defer scWindows.Delete(hwnd) + button := syscall.StringToUTF16Ptr("BUTTON") + for i, label := range []string{"取消", "确认呼叫"} { + text := syscall.StringToUTF16Ptr(label) + child, _, _ := createWindowEx.Call(0, uintptr(unsafe.Pointer(button)), uintptr(unsafe.Pointer(text)), 0x40000000|0x10000, 0, 0, 0, 0, hwnd, uintptr(i+1), instance, 0) // CHILD | TABSTOP + if child == 0 { + destroyWindow.Call(hwnd) + ready <- errors.New("无法创建呼叫确认按钮") + return + } + if i == 0 { + m.cancel = child + } else { + m.confirm = child + } + } + defer destroyWindow.Call(hwnd) + defer func() { + if m.buttonFont != 0 { + hwDeleteObject.Call(m.buttonFont) + } + }() + hwSetTimer.Call(hwnd, 1, 2000, 0) + ready <- nil + var msg trayMessageData + for { + result, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + if int32(result) <= 0 { + break + } + if msg.message == 0x0100 && m.expanded { // keyboard applies to native child buttons too + switch msg.wParam { + case 27: + m.collapse() + continue + case 13: + focus, _, _ := user32.NewProc("GetFocus").Call() + if focus == m.confirm { + m.send() + } else { + m.collapse() + } + continue + case 9: + focus, _, _ := user32.NewProc("GetFocus").Call() + next := m.cancel + if focus == m.cancel && !m.view.Sending { + next = m.confirm + } + user32.NewProc("SetFocus").Call(next) + continue + } + } + translateMessage.Call(uintptr(unsafe.Pointer(&msg))) + dispatchMessage.Call(uintptr(unsafe.Pointer(&msg))) + } +} +func (m *nativeStudentCallWindow) update(view studentCallView) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return errors.New("呼叫窗口已关闭") + } + m.pending = view + if ok, _, err := postMessage.Call(m.hwnd, scUpdate, 0, 0); ok == 0 { + return err + } + return nil +} +func (m *nativeStudentCallWindow) close() { + m.mu.Lock() + if m.closed { + m.mu.Unlock() + return + } + m.closed = true + postMessage.Call(m.hwnd, wmClose, 0, 0) + m.mu.Unlock() + <-m.done +} +func (m *nativeStudentCallWindow) px(v int) int { return v * m.dpi / 96 } +func (m *nativeStudentCallWindow) place() { + style := uintptr(0x80 | 0x08000000) + if m.expanded { + style = 0x80 + } + user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), style) + if dpiProc := user32.NewProc("GetDpiForWindow"); dpiProc.Find() == nil { + if dpi, _, _ := dpiProc.Call(m.hwnd); dpi != 0 { + m.dpi = int(dpi) + } + } + m.width, m.height = m.px(84), m.px(68) + if m.expanded { + m.width, m.height = m.px(420), m.px(240) + } + monitor, _, _ := user32.NewProc("MonitorFromWindow").Call(m.hwnd, 2) + info := monitorInfo{size: uint32(unsafe.Sizeof(monitorInfo{}))} + if ok, _, _ := getMonitorInfo.Call(monitor, uintptr(unsafe.Pointer(&info))); ok == 0 { + return + } + w := info.work + if !m.positioned { + m.x, m.y = int(w.left)+m.px(20), int(w.top)+int(w.bottom-w.top)/2 + m.positioned = true + } + m.width = min(m.width, int(w.right-w.left)) + m.height = min(m.height, int(w.bottom-w.top)) + m.x, m.y = studentCallClamp(m.x, m.y, m.width, m.height, int(w.left), int(w.top), int(w.right), int(w.bottom)) + setWindowPos.Call(m.hwnd, ^uintptr(0), uintptr(m.x), uintptr(m.y), uintptr(m.width), uintptr(m.height), 0x0010) // TOPMOST / NOACTIVATE + radius := m.px(24) + if m.expanded { + radius = m.px(12) + } + region, _, _ := hwRoundRegion.Call(0, 0, uintptr(m.width+1), uintptr(m.height+1), uintptr(radius*2), uintptr(radius*2)) + if region != 0 { + if ok, _, _ := hwSetRegion.Call(m.hwnd, region, 1); ok == 0 { + hwDeleteObject.Call(region) + } + } + if m.fontDPI != m.dpi { + if font := hwFont(m.px(18), 500); font != 0 { + previous := m.buttonFont + m.buttonFont, m.fontDPI = font, m.dpi + for _, button := range []uintptr{m.cancel, m.confirm} { + user32.NewProc("SendMessageW").Call(button, 0x0030, font, 1) // WM_SETFONT + } + if previous != 0 { + hwDeleteObject.Call(previous) + } + } + } + for i, button := range []uintptr{m.cancel, m.confirm} { + buttonWidth := max(1, (m.width-m.px(48))/2) + setWindowPos.Call(button, 0, uintptr(m.px(20)+i*(buttonWidth+m.px(8))), uintptr(m.height-m.px(64)), uintptr(buttonWidth), uintptr(m.px(44)), 0x0004|0x0010) + show := uintptr(0) + if m.expanded { + show = 4 + } + hwShowWindow.Call(button, show) + } + user32.NewProc("EnableWindow").Call(m.confirm, boolUint(!m.view.Sending && m.view.Bound && m.view.Online)) + if m.view.Suspended || !m.view.Bound { + hwShowWindow.Call(m.hwnd, 0) + } else { + hwShowWindow.Call(m.hwnd, 4) + } + hwInvalidate.Call(m.hwnd, 0, 1) +} +func boolUint(b bool) uintptr { + if b { + return 1 + } + return 0 +} +func (m *nativeStudentCallWindow) expand() { + m.expanded = true + // Only this explicit user action activates the confirmation. Idle ball never steals focus. + user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), 0x80) + m.place() + setForegroundWindow.Call(m.hwnd) + user32.NewProc("SetFocus").Call(m.cancel) // Cancel is the safe default. +} +func (m *nativeStudentCallWindow) collapse() { + m.expanded = false + user32.NewProc("SetWindowLongPtrW").Call(m.hwnd, ^uintptr(19), 0x80|0x08000000) + m.place() +} +func (m *nativeStudentCallWindow) send() { + if !m.expanded || m.view.Sending || !m.view.Bound || !m.view.Online || m.view.Suspended { + return + } + m.awaiting, m.sentRevision = true, m.view.Revision + m.view.Sending = true + m.view.Message = "正在发送,请稍候…" + m.place() + // This only queues a runtime event; it does not perform HTTP or wait on WebView. + // Calling before returning keeps pending native snapshots ordered with send state. + m.onConfirm(m.view.Binding) +} +func studentCallWndProc(hwnd, message, wParam, lParam uintptr) uintptr { + if value, ok := scWindows.Load(hwnd); ok { + m := value.(*nativeStudentCallWindow) + switch message { + case scUpdate: + m.mu.Lock() + view := m.pending + m.mu.Unlock() + if m.view.Bound != view.Bound || m.view.Binding != view.Binding || view.Suspended { + m.expanded = false + } + if studentCallAcknowledged(m.view, view, m.sentRevision) { + m.awaiting = false + } + if m.awaiting { + view.Sending = true + } + m.view = view + m.place() + return 0 + case 0x0113, 0x007e, 0x001a: + if !m.down { + m.place() + } + return 0 // timer, display/work-area changes + case 0x02e0: // Keep physical drag position; resize and clamp for the new DPI. + // No dereference of the callback uintptr (also safe under checkptr/vet). + m.dpi = int(wParam & 0xffff) + m.place() + return 0 + case 0x0021: + if !m.expanded { + return 3 + } // MA_NOACTIVATE + case 0x0014: + return 1 + case 0x000f: + m.paint() + return 0 + case 0x0201: + if !m.expanded { + m.down, m.dragged = true, false + getCursorPos.Call(uintptr(unsafe.Pointer(&m.cursor))) + m.origin = trayPoint{int32(m.x), int32(m.y)} + user32.NewProc("SetCapture").Call(hwnd) + } + return 0 + case 0x0200: + if m.down { + var point trayPoint + getCursorPos.Call(uintptr(unsafe.Pointer(&point))) + dx, dy := int(point.x-m.cursor.x), int(point.y-m.cursor.y) + m.dragged = m.dragged || studentCallDragged(dx, dy, m.dpi) + if m.dragged { + m.x, m.y = int(m.origin.x)+dx, int(m.origin.y)+dy + setWindowPos.Call(hwnd, 0, uintptr(m.x), uintptr(m.y), 0, 0, 0x0001|0x0004|0x0010) + } + } + return 0 + case wmLeftButtonUp: + if m.down { + m.down = false + user32.NewProc("ReleaseCapture").Call() + if m.dragged { + m.place() + } else { + m.expand() + } + } + return 0 + case 0x0215: + m.down = false + return 0 // capture cancelled: never turn into a click + case 0x0111: + if wParam&0xffff == 1 { + m.collapse() + } + if wParam&0xffff == 2 { + m.send() + } + return 0 + case wmClose: + m.mu.Lock() + closed := m.closed + m.mu.Unlock() + if closed { + destroyWindow.Call(hwnd) + } else { + m.collapse() + } + return 0 + case wmDestroy: + postQuitMessage.Call(0) + return 0 + } + } + result, _, _ := defWindowProc.Call(hwnd, message, wParam, lParam) + return result +} +func (m *nativeStudentCallWindow) paint() { + var paint hwPaint + dc, _, _ := hwBeginPaint.Call(m.hwnd, uintptr(unsafe.Pointer(&paint))) + if dc == 0 { + return + } + defer hwEndPaint.Call(m.hwnd, uintptr(unsafe.Pointer(&paint))) + full := displayRect{0, 0, int32(m.width), int32(m.height)} + fill := [3]uint8{243, 249, 245} + ink := [3]uint8{25, 94, 67} + brush, _, _ := hwCreateBrush.Call(hwRGB(fill)) + hwFillRect.Call(dc, uintptr(unsafe.Pointer(&full)), brush) + hwDeleteObject.Call(brush) + hwSetBkMode.Call(dc, 1) + hwSetTextColor.Call(dc, hwRGB(ink)) + font := hwFont(m.px(18), 600) + old, _, _ := hwSelectObject.Call(dc, font) + defer func() { hwSelectObject.Call(dc, old); hwDeleteObject.Call(font) }() + if !m.expanded { + hwText(dc, "呼叫\n老师", &full, 0x0001|0x0010) + return + } + title := displayRect{int32(m.px(20)), int32(m.px(16)), int32(m.width - m.px(20)), int32(m.px(48))} + hwText(dc, "呼叫老师", &title, 0x0020) + body := displayRect{title.left, int32(m.px(58)), title.right, int32(m.height - m.px(72))} + text := m.view.ClassName + ":确定呼叫本班所有关联老师吗?\n取消不会发送。" + if !m.view.Online { + text = "连接中断,呼叫未发送。请恢复网络后重试。" + } + if m.view.Message != "" { + text = m.view.Message + } + hwText(dc, text, &body, 0x0010) +} diff --git a/screen/wails.json b/screen/wails.json index 31dc5ab..64ff2b0 100644 --- a/screen/wails.json +++ b/screen/wails.json @@ -11,7 +11,7 @@ "info": { "companyName": "ClassDing", "productName": "课堂叮咚大屏端", - "productVersion": "0.3.4", + "productVersion": "0.3.5", "copyright": "ClassDing" } } diff --git a/scripts/api-generator/package.json b/scripts/api-generator/package.json index 51bf508..9bd6a24 100644 --- a/scripts/api-generator/package.json +++ b/scripts/api-generator/package.json @@ -1,6 +1,6 @@ { "name": "@classding/api-generator", - "version": "0.3.4", + "version": "0.3.5", "private": true, "type": "module", "scripts": { diff --git a/scripts/generate-contract.mjs b/scripts/generate-contract.mjs index b5b7706..f0222c3 100644 --- a/scripts/generate-contract.mjs +++ b/scripts/generate-contract.mjs @@ -86,6 +86,18 @@ const schemas = { joined_at: timestamp, }), Phrase: obj({ id: str, text: str, sort: num }), + StudentCall: obj({ + id: str, + class_id: str, + class_name: str, + device_id: str, + device_name: str, + created_at: timestamp, + read_at: str, + read_by: str, + read_by_name: str, + }), + StudentCallList: obj({ items: arr(ref('StudentCall')), unread_count: num, next_cursor: nullable }), Homework: homework, HomeworkList: obj({ items: arr(ref('Homework')) }), DeviceHomeworkList: obj({ items: arr(ref('Homework')), homework_revision: homeworkRevision }), @@ -352,6 +364,18 @@ add( ); add('/homeworks/{id}', 'delete', ref('OK')); add('/messages', 'get', ref('MessageList')); +add( + '/device/student-calls', + 'post', + ref('StudentCall'), + obj({ request_id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9_.:-]+$' } }), +); +add('/student-calls', 'get', ref('StudentCallList')); +paths['/student-calls'].get.parameters = [ + ...['class_id', 'cursor'].map((name) => ({ name, in: 'query', required: false, schema: str })), + { name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 100 } }, +]; +add('/student-calls/{id}/read', 'post', ref('StudentCall')); add('/messages', 'post', ref('Message'), ref('MessageInput')); add('/messages/{id}', 'get', ref('Message')); add('/messages/{id}', 'delete', ref('OK')); @@ -521,6 +545,7 @@ const frames = { pong: obj({ server_time: num }), receipt: obj({ message_id: str, class_id: str, device_id: str, status: str, at: timestamp }), presence: obj({ class_id: str, device_id: str, online: bool }), + student_call: obj({ id: str, class_id: str, kind: { type: 'string', enum: ['created', 'read'] } }), }; writeFileSync( 'api/ws/frame.schema.json', diff --git a/server/README.md b/server/README.md index a817a29..fda5691 100644 --- a/server/README.md +++ b/server/README.md @@ -40,6 +40,12 @@ go build ./cmd/classding 服务支持 HTTP 刷新会话;管理端和教师端使用独立 HttpOnly Cookie。HTTPS 部署可强制设置 `COOKIE_SECURE=true`。旧 `ADMIN_TOTP_SECRET` 环境变量将被忽略,可从部署配置移除。使用容器部署时,需确保上述所需环境变量确实传入容器;尤其在反向代理后按客户端限流时,必须正确配置可信代理。切勿将公网或不受控网段当作可信代理,否则客户端可能伪造来源地址。 +## 学生呼叫(未发布) + +已绑定且授权有效的设备可向当前班级发起固定内容呼叫,教师仅能查看、处理自己加入班级的记录。呼叫和首次处理老师、时间保存在 SQLite,并随全站备份恢复;旧备份不含呼叫集合时按空集合兼容。当前成员在线时由教师 WebSocket 提醒,离线后通过列表补查,不使用 Web Push。 + +同班新呼叫间隔 30 秒,设备请求以 `request_id` 幂等重试;仅显式标记已读才改变全班共享状态。接口、分页及错误语义见 [`docs/INTEGRATION.md`](../docs/INTEGRATION.md#学生呼叫未发布),验收见 [`docs/LOCAL_TESTING.md`](../docs/LOCAL_TESTING.md#学生呼叫验收未发布)。 + ## 数据与当前限制 当前 SQLite 使用 WAL,持久化按实体保存 JSON 并建索引,启动时载入内存,写入由主服务串行处理;它不是开发方案中规划的规范化 sqlc 仓储。**同一数据目录只能由一个主服务进程使用**,不支持多实例共享数据库。 diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 4203b45..b8154d6 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -102,6 +102,9 @@ func New(c Config) (*App, error) { // upgradeState fills in data that databases written by older releases lack. func upgradeState(s *State) { + if s.StudentCalls == nil { + s.StudentCalls = map[string]studentCallRecord{} + } initializeSettings(s) if s.Schools == nil { s.Schools = map[string]School{} diff --git a/server/internal/app/backup.go b/server/internal/app/backup.go index a93f357..9edb38e 100644 --- a/server/internal/app/backup.go +++ b/server/internal/app/backup.go @@ -184,7 +184,7 @@ func (a *App) backup(w http.ResponseWriter, r *http.Request) { // Validate every relationship that is required for live entities. Historical // messages and audio can legitimately refer to since-deleted users/classes/devices. func validateBackupState(s State) error { - if s.Users == nil || s.Classes == nil || s.Members == nil || s.Devices == nil || s.Messages == nil || s.Homeworks == nil || s.Codes == nil || s.Invites == nil || s.Sessions == nil || s.Phrases == nil || s.Audio == nil || s.Audit == nil || s.Settings == nil { + if s.Users == nil || s.Classes == nil || s.Members == nil || s.Devices == nil || s.Messages == nil || s.Homeworks == nil || s.StudentCalls == nil || s.Codes == nil || s.Invites == nil || s.Sessions == nil || s.Phrases == nil || s.Audio == nil || s.Audit == nil || s.Settings == nil { return errors.New("missing state collection") } if _, ok := s.Settings["site_name"]; !ok { @@ -251,6 +251,27 @@ func validateBackupState(s State) error { return errors.New("too many homeworks") } } + requestIDs := map[string]bool{} + for key, item := range s.StudentCalls { + _, classExists := s.Classes[item.ClassID] + created, createdOK := parseTime(item.CreatedAt) + if key == "" || item.ID != key || !classExists || strings.TrimSpace(item.ClassName) == "" || item.DeviceID == "" || strings.TrimSpace(item.DeviceName) == "" || !createdOK || !studentCallRequestID.MatchString(item.RequestID) || requestIDs[item.RequestID] { + return errors.New("invalid student call") + } + requestIDs[item.RequestID] = true + if item.ReadAt == "" { + if item.ReadBy != "" || item.ReadByName != "" { + return errors.New("invalid unread student call") + } + } else { + read, readOK := parseTime(item.ReadAt) + if !readOK || read.Before(created) || item.ReadBy == "" || strings.TrimSpace(item.ReadByName) == "" { + return errors.New("invalid read student call") + } + } + // Device and processing-teacher identities are historical snapshots: the + // device can be unbound/rebound elsewhere and the teacher can be deleted. + } tokens := map[string]bool{} for key, d := range s.Devices { _, exists := s.Classes[d.ClassID] @@ -423,6 +444,27 @@ func prepareRestore(zipPath, stage string) (State, error) { } } } + // Older version-1 archives predate student calls; only absence is compatible. + // A present collection and its records must not use null/missing fields to + // silently erase replay keys or turn a processed request back into unread. + if state.StudentCalls == nil { + if _, exists := collections["student_calls"]; exists { + return empty, errors.New("invalid student call collection") + } + state.StudentCalls = map[string]studentCallRecord{} + } else { + var items map[string]map[string]json.RawMessage + if json.Unmarshal(collections["student_calls"], &items) != nil { + return empty, errors.New("invalid student call collection") + } + for _, fields := range items { + for _, key := range []string{"id", "class_id", "class_name", "device_id", "device_name", "created_at", "read_at", "read_by", "read_by_name", "request_id"} { + if value, exists := fields[key]; !exists || bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return empty, errors.New("missing student call field") + } + } + } + } if err := validateBackupState(state); err != nil { return empty, err } diff --git a/server/internal/app/classes.go b/server/internal/app/classes.go index 1684cea..295b3a5 100644 --- a/server/internal/app/classes.go +++ b/server/internal/app/classes.go @@ -195,6 +195,11 @@ func (a *App) deleteClass(_ http.ResponseWriter, r *http.Request, u *User) (any, // removeClass deletes the class with everything that belongs to it and // releases its license for reuse. func (a *App) removeClass(c Class, u *User) { + for key, item := range a.state.StudentCalls { + if item.ClassID == c.ID { + delete(a.state.StudentCalls, key) + } + } for key, item := range a.state.Homeworks { if item.ClassID == c.ID { delete(a.state.Homeworks, key) diff --git a/server/internal/app/hub.go b/server/internal/app/hub.go index 96a46ea..4e74b00 100644 --- a/server/internal/app/hub.go +++ b/server/internal/app/hub.go @@ -19,6 +19,8 @@ type Peer struct { ID string // Device ID, for screens. ClassID string // For screens. UserID string // For teachers. + // Immutable once registered; student-call broadcasts recheck the live session. + teacherSession claims } func newPeer(parent context.Context, conn *websocket.Conn) *Peer { diff --git a/server/internal/app/models.go b/server/internal/app/models.go index ae7ff7c..09ebd76 100644 --- a/server/internal/app/models.go +++ b/server/internal/app/models.go @@ -3,7 +3,7 @@ package app import "time" // Version is the release reported by the server and offered to classroom clients. -const Version = "0.3.4" +const Version = "0.3.5" // Role decides which workspace an account may sign in to and what it governs. type Role string @@ -293,41 +293,63 @@ type Audit struct { At string `json:"at"` } +// StudentCall is a shared classroom request. Names are immutable event snapshots. +// Read fields deliberately have no omitempty: unread values are empty strings. +type StudentCall struct { + ID string `json:"id"` + ClassID string `json:"class_id"` + ClassName string `json:"class_name"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + CreatedAt string `json:"created_at"` + ReadAt string `json:"read_at"` + ReadBy string `json:"read_by"` + ReadByName string `json:"read_by_name"` +} + +// studentCallRecord keeps replay keys in storage/backups, never in API responses. +type studentCallRecord struct { + StudentCall + RequestID string `json:"request_id"` +} + // State is the whole database. It lives in memory under App.mu and is written // through to SQLite after every successful change. type State struct { - Users map[string]User `json:"users"` - Classes map[string]Class `json:"classes"` - Members map[string]Member `json:"class_members"` - Devices map[string]Device `json:"devices"` - Messages map[string]Message `json:"messages"` - Homeworks map[string]Homework `json:"homeworks"` - Codes map[string]Code `json:"activation_codes"` - Invites map[string]Invite `json:"collab_invites"` - Sessions map[string]Session `json:"sessions"` - Phrases map[string]Phrase `json:"phrases"` - Audio map[string]Audio `json:"audio"` - Audit map[string]Audit `json:"audit_logs"` - Settings map[string]string `json:"settings"` - Schools map[string]School `json:"schools"` + StudentCalls map[string]studentCallRecord `json:"student_calls"` + Users map[string]User `json:"users"` + Classes map[string]Class `json:"classes"` + Members map[string]Member `json:"class_members"` + Devices map[string]Device `json:"devices"` + Messages map[string]Message `json:"messages"` + Homeworks map[string]Homework `json:"homeworks"` + Codes map[string]Code `json:"activation_codes"` + Invites map[string]Invite `json:"collab_invites"` + Sessions map[string]Session `json:"sessions"` + Phrases map[string]Phrase `json:"phrases"` + Audio map[string]Audio `json:"audio"` + Audit map[string]Audit `json:"audit_logs"` + Settings map[string]string `json:"settings"` + Schools map[string]School `json:"schools"` } func newState() State { return State{ - Users: map[string]User{}, - Classes: map[string]Class{}, - Members: map[string]Member{}, - Devices: map[string]Device{}, - Messages: map[string]Message{}, - Homeworks: map[string]Homework{}, - Codes: map[string]Code{}, - Invites: map[string]Invite{}, - Sessions: map[string]Session{}, - Phrases: map[string]Phrase{}, - Audio: map[string]Audio{}, - Audit: map[string]Audit{}, - Settings: map[string]string{}, - Schools: map[string]School{}, + StudentCalls: map[string]studentCallRecord{}, + Users: map[string]User{}, + Classes: map[string]Class{}, + Members: map[string]Member{}, + Devices: map[string]Device{}, + Messages: map[string]Message{}, + Homeworks: map[string]Homework{}, + Codes: map[string]Code{}, + Invites: map[string]Invite{}, + Sessions: map[string]Session{}, + Phrases: map[string]Phrase{}, + Audio: map[string]Audio{}, + Audit: map[string]Audit{}, + Settings: map[string]string{}, + Schools: map[string]School{}, } } diff --git a/server/internal/app/realtime.go b/server/internal/app/realtime.go index fdfea13..13b1038 100644 --- a/server/internal/app/realtime.go +++ b/server/internal/app/realtime.go @@ -366,6 +366,7 @@ func (a *App) connectTeacher(p *Peer, token string) (claims, bool) { return c, false } p.UserID = u.ID + p.teacherSession = c a.hub.addTeacher(p) p.send(Frame{T: "welcome", D: map[string]any{"server_time": time.Now().Unix()}}) return c, true diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go index f489cde..21a934c 100644 --- a/server/internal/app/routes.go +++ b/server/internal/app/routes.go @@ -54,6 +54,9 @@ func (a *App) routes() { r.Post("/device/bind", c(public, a.bindDevice)) r.Get("/device/me", q(public, a.deviceMe)) r.Get("/device/homeworks", q(public, a.deviceHomeworks)) + r.Post("/device/student-calls", c(public, a.createStudentCall)) + r.Get("/student-calls", q(signedIn, a.listStudentCalls)) + r.Post("/student-calls/{id}/read", c(signedIn, a.readStudentCall)) r.Post("/device/migration-check", q(public, a.deviceMigrationCheck)) r.Patch("/devices/{id}", c(signedIn, a.renameDevice)) r.Delete("/devices/{id}", c(signedIn, a.removeDevice)) diff --git a/server/internal/app/student_calls.go b/server/internal/app/student_calls.go new file mode 100644 index 0000000..2c85e11 --- /dev/null +++ b/server/internal/app/student_calls.go @@ -0,0 +1,203 @@ +package app + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const studentCallCooldown = 30 * time.Second + +var studentCallRequestID = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,128}$`) + +func (a *App) createStudentCall(w http.ResponseWriter, r *http.Request, _ *User) (any, error) { + d, c, err := a.boundDevice(r) + if err != nil { + return nil, err + } + if !a.licensed(c) { + return nil, licenseInactive() + } + var in struct { + RequestID string `json:"request_id"` + } + if err := decodeStrict(r, &in); err != nil { + return nil, err + } + if !studentCallRequestID.MatchString(in.RequestID) { + return nil, bad("request_id 需为 1–128 位字母、数字或 _ . : -。") + } + // Keys are globally reserved, not scoped to a device or a class. Authenticate + // and validate the current binding/license before returning even a replay. + for _, item := range a.state.StudentCalls { + if item.RequestID == in.RequestID { + if item.DeviceID != d.ID || item.ClassID != c.ID { + return nil, fail(409, "REQUEST_ID_CONFLICT", "request_id 已被使用,请使用新的请求标识。") + } + return item.StudentCall, nil + } + } + at := time.Now() + for _, item := range a.state.StudentCalls { + created, valid := parseTime(item.CreatedAt) + if item.ClassID == c.ID && valid && at.Sub(created) < studentCallCooldown { + remaining := studentCallCooldown - at.Sub(created) + seconds := (remaining + time.Second - 1) / time.Second + w.Header().Set("Retry-After", strconv.FormatInt(int64(seconds), 10)) + return nil, tooMany("该班刚刚发起过呼叫,请稍后再试。") + } + } + item := studentCallRecord{StudentCall: StudentCall{ID: id("sc"), ClassID: c.ID, ClassName: c.Name, DeviceID: d.ID, DeviceName: d.Name, CreatedAt: stamp(at)}, RequestID: in.RequestID} + a.state.StudentCalls[item.ID] = item + a.studentCallChanged(item, "created") + return item.StudentCall, nil +} + +type studentCallCursor struct { + CreatedAt string `json:"created_at"` + ID string `json:"id"` +} + +type studentCallList struct { + Items []StudentCall `json:"items"` + UnreadCount int `json:"unread_count"` + NextCursor *string `json:"next_cursor"` +} + +// Compare parsed times rather than RFC3339Nano strings: a whole second and a +// fractional second do not sort lexically at the boundary. +func compareStudentCalls(x, y StudentCall) int { + left, _ := parseTime(x.CreatedAt) + right, _ := parseTime(y.CreatedAt) + if order := right.Compare(left); order != 0 { + return order + } + return strings.Compare(y.ID, x.ID) +} + +func (a *App) listStudentCalls(_ http.ResponseWriter, r *http.Request, u *User) (any, error) { + query := r.URL.Query() + classID := query.Get("class_id") + if classID != "" { + if _, err := a.getClass(u, classID, false); err != nil { + return nil, err + } + } + limit := 100 + if raw, exists := query["limit"]; exists { + var err error + if len(raw) != 1 { + return nil, bad("limit 需为 1–100。") + } + limit, err = strconv.Atoi(raw[0]) + if err != nil || limit < 1 || limit > 100 { + return nil, bad("limit 需为 1–100。") + } + } + var cursor *StudentCall + if raw := query.Get("cursor"); raw != "" { + if len(raw) > 1024 { + return nil, bad("分页标识无效。") + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, bad("分页标识无效。") + } + var key studentCallCursor + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&key) != nil || key.ID == "" || len(key.ID) > 128 { + return nil, bad("分页标识无效。") + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return nil, bad("分页标识无效。") + } + if _, valid := parseTime(key.CreatedAt); !valid { + return nil, bad("分页标识无效。") + } + cursor = &StudentCall{ID: key.ID, CreatedAt: key.CreatedAt} + } + result := studentCallList{Items: []StudentCall{}} + for _, item := range a.state.StudentCalls { + c, exists := a.state.Classes[item.ClassID] + if !exists || classID != "" && classID != item.ClassID || !a.canClass(u, c) { + continue + } + if item.ReadAt == "" { + result.UnreadCount++ + } + if cursor == nil || compareStudentCalls(item.StudentCall, *cursor) > 0 { + result.Items = append(result.Items, item.StudentCall) + } + } + slices.SortFunc(result.Items, compareStudentCalls) + if len(result.Items) > limit { + result.Items = result.Items[:limit] + last := result.Items[len(result.Items)-1] + key, _ := json.Marshal(studentCallCursor{CreatedAt: last.CreatedAt, ID: last.ID}) + encoded := base64.RawURLEncoding.EncodeToString(key) + result.NextCursor = &encoded + } + return result, nil +} + +func (a *App) readStudentCall(_ http.ResponseWriter, r *http.Request, u *User) (any, error) { + item, exists := a.state.StudentCalls[chi.URLParam(r, "id")] + if !exists { + return nil, missing() + } + if _, err := a.getClass(u, item.ClassID, false); err != nil { + return nil, err + } + // A read is a shared acknowledgement. The first processor is authoritative; + // retries and other teachers must not overwrite them or broadcast again. + if item.ReadAt == "" { + item.ReadAt, item.ReadBy, item.ReadByName = now(), u.ID, u.DisplayName + a.state.StudentCalls[item.ID] = item + a.audit(u, "student_call.read", item.ID) + a.studentCallChanged(item, "read") + } + return item.StudentCall, nil +} + +// broadcastStudentCall runs with App.mu held. A connected socket is not proof +// of authorization: its token may expire or be retired before the next ping. +func (a *App) broadcastStudentCall(classID string, frame Frame) { + var peers []*Peer + a.hub.mu.Lock() + for _, uid := range a.teachers(classID) { + u := a.state.Users[uid] + for p := range a.hub.teachers[uid] { + session := p.teacherSession + if session.Subject == uid && session.Expires > time.Now().Unix() && session.Version == u.TokenVersion && session.Epoch == a.authEpoch() { + peers = append(peers, p) + } + } + } + a.hub.mu.Unlock() + sendAll(peers, frame) +} + +func (a *App) studentCallChanged(item studentCallRecord, kind string) { + a.deferJob(func() { + a.mu.Lock() + defer a.mu.Unlock() + // Recheck live membership after commit, not at enqueue time: a teacher may + // have left or been disabled, or the class may have been deleted meanwhile. + current, exists := a.state.StudentCalls[item.ID] + if !exists || current.ClassID != item.ClassID { + return + } + a.broadcastStudentCall(item.ClassID, Frame{T: "student_call", D: map[string]any{"id": item.ID, "class_id": item.ClassID, "kind": kind}}) + }) +} diff --git a/server/internal/app/student_calls_backup_test.go b/server/internal/app/student_calls_backup_test.go new file mode 100644 index 0000000..1840220 --- /dev/null +++ b/server/internal/app/student_calls_backup_test.go @@ -0,0 +1,136 @@ +package app + +import ( + "context" + "encoding/json" + "testing" +) + +func mutateStudentBackup(t *testing.T, archive []byte, mutate func(map[string]any)) []byte { + t.Helper() + return alterBackup(t, archive, func(files map[string][]byte) { + var manifest map[string]any + if err := json.Unmarshal(files["manifest.json"], &manifest); err != nil { + t.Fatal(err) + } + mutate(manifest["state"].(map[string]any)) + files["manifest.json"], _ = json.Marshal(manifest) + }) +} + +func TestStudentCallsBackupCompatibilityRoundTripAndValidation(t *testing.T) { + src := prepareBackupFixture(t) + item := postStudentCall(t, src, "token-a", "backup-key") + expectStatus(t, src.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", src.token("collab"), nil, nil), 200) + archive := getBackup(t, src) + dst := newSecurityFixture(t) + expectStatus(t, restoreBackup(t, dst, dst.token("super"), archive), 200) + if got := dst.app.state.StudentCalls[item.ID]; got.RequestID != "backup-key" || got.ReadBy != "collab_a" { + t.Fatalf("restore lost call: %+v", got) + } + var persisted State + if err := dst.app.store.Load(context.Background(), &persisted); err != nil || persisted.StudentCalls[item.ID] != dst.app.state.StudentCalls[item.ID] { + t.Fatal("restore not persisted", err) + } + if replay := postStudentCall(t, dst, "token-a", "backup-key"); replay.ID != item.ID || replay.ReadBy != "collab_a" { + t.Fatal("restore lost replay or read state") + } + expectStatus(t, dst.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "too-soon"}, nil), 429) + legacy := mutateStudentBackup(t, archive, func(s map[string]any) { delete(s, "student_calls") }) + old := newSecurityFixture(t) + expectStatus(t, restoreBackup(t, old, old.token("super"), legacy), 200) + if old.app.state.StudentCalls == nil || len(old.app.state.StudentCalls) != 0 { + t.Fatal("legacy backup not upgraded") + } + postStudentCall(t, old, "token-a", "new-after-legacy") + + for _, tc := range []struct { + name string + mutate func(map[string]any) + }{ + {"null collection", func(s map[string]any) { s["student_calls"] = nil }}, + {"missing class", func(i map[string]any) { i["class_id"] = "missing" }}, + {"key mismatch", func(i map[string]any) { i["id"] = "mismatch" }}, + {"empty device", func(i map[string]any) { i["device_id"] = "" }}, + {"empty device name", func(i map[string]any) { i["device_name"] = " " }}, + {"empty class name", func(i map[string]any) { i["class_name"] = " " }}, + {"invalid created time", func(i map[string]any) { i["created_at"] = "invalid" }}, + {"read before creation", func(i map[string]any) { i["read_at"] = "2000-01-01T00:00:00Z" }}, + {"invalid read time", func(i map[string]any) { i["read_at"] = "invalid" }}, + {"no processor", func(i map[string]any) { i["read_by"] = "" }}, + {"no processor name", func(i map[string]any) { i["read_by_name"] = " " }}, + {"partial unread", func(i map[string]any) { i["read_at"] = "" }}, + {"bad request key", func(i map[string]any) { i["request_id"] = " whitespace " }}, + {"missing read field", func(i map[string]any) { delete(i, "read_at") }}, + {"missing request key", func(i map[string]any) { delete(i, "request_id") }}, + {"null read field", func(i map[string]any) { i["read_by_name"] = nil }}, + {"duplicate request key", func(s map[string]any) { + calls := s["student_calls"].(map[string]any) + copy := map[string]any{} + for k, v := range calls[item.ID].(map[string]any) { + copy[k] = v + } + copy["id"] = "duplicate" + calls["duplicate"] = copy + }}, + } { + t.Run(tc.name, func(t *testing.T) { + corrupt := mutateStudentBackup(t, archive, func(s map[string]any) { + if tc.name == "null collection" || tc.name == "duplicate request key" { + tc.mutate(s) + } else { + tc.mutate(s["student_calls"].(map[string]any)[item.ID].(map[string]any)) + } + }) + f := newSecurityFixture(t) + expectStatus(t, restoreBackup(t, f, f.token("super"), corrupt), 400) + if len(f.app.state.StudentCalls) != 0 { + t.Fatal("invalid backup mutated live calls") + } + }) + } +} + +func TestStudentCallsHistoricalDevicesAndProcessorsSurviveBackup(t *testing.T) { + src := prepareBackupFixture(t) + item := postStudentCall(t, src, "token-a", "history") + expectStatus(t, src.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", src.token("collab"), nil, nil), 200) + // Real administrative deletion must preserve the first processor snapshot. + expectStatus(t, src.request(t, "DELETE", "/api/v1/admin/users/collab_a", src.token("school"), nil, nil), 200) + expectStatus(t, src.request(t, "DELETE", "/api/v1/devices/device_a", src.token("owner"), nil, nil), 200) + archive := getBackup(t, src) + for _, rebound := range []bool{false, true} { + data := archive + if rebound { + // A restored historical ID may now describe a replacement binding in a + // different class. It must not invalidate the original call snapshot. + data = mutateStudentBackup(t, archive, func(s map[string]any) { + s["devices"].(map[string]any)["device_a"] = map[string]any{"id": "device_a", "class_id": "class_b", "name": "replacement", "token_hash": hash("replacement-token")} + }) + // Legacy notification validation also checks existing device/class pairs; + // remove those unrelated historical messages for this focused scenario. + data = mutateStudentBackup(t, data, func(s map[string]any) { s["messages"] = map[string]any{} }) + } + dst := newSecurityFixture(t) + expectStatus(t, restoreBackup(t, dst, dst.token("super"), data), 200) + got := dst.app.state.StudentCalls[item.ID] + if got.ReadBy != "collab_a" || got.ReadByName != "乙老师" || got.DeviceName != "甲设备" || got.ClassID != "class_a" { + t.Fatalf("history changed: %+v", got) + } + } +} + +func TestStudentCallsClassDeletionRollsBack(t *testing.T) { + f := newSecurityFixture(t) + item := postStudentCall(t, f, "token-a", "delete-rollback") + restore := closeStoreForRollback(t, f.app) + expectStatus(t, f.request(t, "DELETE", "/api/v1/classes/class_a", f.token("owner"), nil, nil), 500) + if f.app.state.StudentCalls[item.ID].ID != item.ID { + t.Fatal("failed class deletion lost calls") + } + restore() + expectStatus(t, f.request(t, "DELETE", "/api/v1/classes/class_a", f.token("owner"), nil, nil), 200) + if len(f.app.state.StudentCalls) != 0 { + t.Fatal("class deletion did not remove calls") + } +} diff --git a/server/internal/app/student_calls_realtime_test.go b/server/internal/app/student_calls_realtime_test.go new file mode 100644 index 0000000..b839e60 --- /dev/null +++ b/server/internal/app/student_calls_realtime_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +func TestStudentCallsRetiredPeerSessions(t *testing.T) { + for _, mode := range []string{"expired", "version", "epoch", "subject"} { + t.Run(mode, func(t *testing.T) { + f := newSecurityFixture(t) + good := callPeer(t, f.app, "owner_a") + bad := callPeer(t, f.app, "owner_a") + switch mode { + case "expired": + bad.teacherSession.Expires = time.Now().Unix() + case "version": + bad.teacherSession.Version++ + case "epoch": + bad.teacherSession.Epoch = "retired" + case "subject": + bad.teacherSession.Subject = "owner_b" + } + item := postStudentCall(t, f, "token-a", "retired") + takeCallFrame(t, good, item, "created") + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 200) + takeCallFrame(t, good, item, "read") + if len(bad.queue) != 0 { + t.Fatal("retired session received a call event") + } + }) + } +} + +func connectCallTeacher(t *testing.T, base, token string) *websocket.Conn { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(base, "http")+"/ws/teacher", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.CloseNow() }) + if err := wsjson.Write(ctx, conn, map[string]any{"t": "hello", "d": map[string]string{"token": token}}); err != nil { + t.Fatal(err) + } + rtRead(t, conn, "welcome") + return conn +} + +func TestStudentCallsRealWebsocketAndReconnect(t *testing.T) { + f := newSecurityFixture(t) + server := httptest.NewServer(f.app.Handler()) + t.Cleanup(server.Close) + owner := connectCallTeacher(t, server.URL, f.token("owner")) + collab := connectCallTeacher(t, server.URL, f.token("collab")) + item := postStudentCall(t, f, "token-a", "online") + for _, conn := range []*websocket.Conn{owner, collab} { + frame := rtRead(t, conn, "student_call") + data := frame.D.(map[string]any) + if data["id"] != item.ID || data["class_id"] != "class_a" || data["kind"] != "created" || len(data) != 3 { + t.Fatalf("bad websocket payload: %+v", frame) + } + } + collab.CloseNow() + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("owner"), nil, nil), 200) + frame := rtRead(t, owner, "student_call") + if frame.D.(map[string]any)["kind"] != "read" { + t.Fatal("missing read event") + } + connectCallTeacher(t, server.URL, f.token("collab")) + var got studentCallList + rtRequest(t, server.URL, "GET", "/student-calls", f.token("collab"), nil, 200, &got) + if got.UnreadCount != 0 || len(got.Items) != 1 || got.Items[0].ReadBy != "owner_a" { + t.Fatalf("offline/reconnected shared read lost: %+v", got) + } +} diff --git a/server/internal/app/student_calls_test.go b/server/internal/app/student_calls_test.go new file mode 100644 index 0000000..3d1ad8d --- /dev/null +++ b/server/internal/app/student_calls_test.go @@ -0,0 +1,382 @@ +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +const studentCallsPath = "/api/v1/student-calls" +const deviceCallsPath = "/api/v1/device/student-calls" + +func postStudentCall(t *testing.T, f *securityFixture, token, key string) StudentCall { + t.Helper() + w := f.request(t, "POST", deviceCallsPath, token, map[string]string{"request_id": key}, nil) + expectStatus(t, w, 200) + var item StudentCall + if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil { + t.Fatal(err) + } + if strings.Contains(w.Body.String(), "request_id") { + t.Fatal("internal replay key leaked") + } + for _, key := range []string{"read_at", "read_by", "read_by_name"} { + if !strings.Contains(w.Body.String(), `"`+key+`":""`) && item.ReadAt == "" { + t.Fatalf("missing empty field %s: %s", key, w.Body) + } + } + return item +} + +func listStudentCall(t *testing.T, f *securityFixture, who, suffix string) studentCallList { + t.Helper() + w := f.request(t, "GET", studentCallsPath+suffix, f.token(who), nil, nil) + expectStatus(t, w, 200) + var list studentCallList + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatal(err) + } + return list +} + +func callPeer(t *testing.T, a *App, uid string) *Peer { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + u := a.state.Users[uid] + session, _ := a.verify(a.access(u)) + p := &Peer{ctx: ctx, cancel: cancel, UserID: uid, queue: make(chan Frame, 256), teacherSession: session} + a.hub.addTeacher(p) + t.Cleanup(func() { cancel(); a.hub.removeTeacherPeer(p) }) + return p +} + +func takeCallFrame(t *testing.T, p *Peer, item StudentCall, kind string) { + t.Helper() + select { + case frame := <-p.queue: + if frame.T != "student_call" || !reflect.DeepEqual(frame.D, map[string]any{"id": item.ID, "class_id": item.ClassID, "kind": kind}) { + t.Fatalf("wrong frame: %+v", frame) + } + default: + t.Fatal("missing call frame") + } +} + +func TestStudentCallsPermissionsReplayAndSharedRead(t *testing.T) { + f := newSecurityFixture(t) + owner := callPeer(t, f.app, "owner_a") + collab := callPeer(t, f.app, "collab_a") + outsiders := []*Peer{callPeer(t, f.app, "school_a"), callPeer(t, f.app, "super"), callPeer(t, f.app, "owner_b"), callPeer(t, f.app, "outsider")} + item := postStudentCall(t, f, "token-a", "request-1") + if item.ClassID != "class_a" || item.DeviceID != "device_a" || item.ClassName != "甲班" || item.DeviceName != "甲设备" || item.ReadAt != "" { + t.Fatalf("wrong snapshot: %+v", item) + } + for _, p := range []*Peer{owner, collab} { + takeCallFrame(t, p, item, "created") + } + if replay := postStudentCall(t, f, "token-a", "request-1"); replay != item { + t.Fatal("replay changed record") + } + for _, who := range []string{"school", "super", "other", "outsider"} { + if list := listStudentCall(t, f, who, ""); len(list.Items) != 0 || list.UnreadCount != 0 { + t.Fatalf("%s leaked calls", who) + } + expectStatus(t, f.request(t, "GET", studentCallsPath+"?class_id=class_a", f.token(who), nil, nil), 403) + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token(who), nil, nil), 403) + } + expectStatus(t, f.request(t, "GET", studentCallsPath, "", nil, nil), 401) + expectStatus(t, f.request(t, "GET", studentCallsPath, "token-a", nil, nil), 401) + expectStatus(t, f.request(t, "POST", deviceCallsPath, f.token("owner"), map[string]string{"request_id": "teacher"}, nil), 401) + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", "token-a", nil, nil), 401) + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-b", map[string]string{"request_id": "request-1"}, nil), 409) + w := f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "request-2"}, nil) + expectStatus(t, w, 429) + if w.Header().Get("Retry-After") == "" { + t.Fatal("cooldown lacks Retry-After") + } + for _, who := range []string{"owner", "collab"} { + if got := listStudentCall(t, f, who, ""); got.UnreadCount != 1 || len(got.Items) != 1 || got.Items[0] != item { + t.Fatal("list should not auto-read") + } + } + w = f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil) + expectStatus(t, w, 200) + var read StudentCall + if err := json.Unmarshal(w.Body.Bytes(), &read); err != nil { + t.Fatal(err) + } + if read.ReadAt == "" || read.ReadBy != "collab_a" || read.ReadByName != "乙老师" { + t.Fatalf("wrong processing snapshot: %+v", read) + } + for _, p := range []*Peer{owner, collab} { + takeCallFrame(t, p, item, "read") + } + w = f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("owner"), nil, nil) + expectStatus(t, w, 200) + var again StudentCall + _ = json.Unmarshal(w.Body.Bytes(), &again) + if again != read || postStudentCall(t, f, "token-a", "request-1") != read { + t.Fatal("idempotent read/replay changed first processor") + } + if got := listStudentCall(t, f, "owner", ""); got.UnreadCount != 0 || len(got.Items) != 1 { + t.Fatal("shared read lost record or unread did not clear") + } + for _, p := range append(outsiders, owner, collab) { + if len(p.queue) != 0 { + t.Fatal("unauthorized or duplicate event") + } + } +} + +func TestStudentCallsStrictInputBindingAndLicenses(t *testing.T) { + f := newSecurityFixture(t) + for _, body := range []any{nil, map[string]any{}, map[string]any{"request_id": nil}, map[string]any{"request_id": 3}, map[string]any{"request_id": ""}, map[string]any{"request_id": " white "}, map[string]any{"request_id": strings.Repeat("a", 129)}, map[string]any{"request_id": "中文"}, map[string]any{"request_id": "ok", "class_id": "class_b"}, map[string]any{"request_id": "ok", "device_id": "device_b"}} { + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", body, nil), 400) + } + for _, raw := range []string{`null`, `{"request_id":"ok"}{}`, `["ok"]`} { + req := httptest.NewRequest("POST", deviceCallsPath, strings.NewReader(raw)) + req.Header.Set("Authorization", "Bearer token-a") + w := httptest.NewRecorder() + f.app.Handler().ServeHTTP(w, req) + expectStatus(t, w, 400) + } + item := postStudentCall(t, f, "token-a", "aA0_.:-") + d := f.app.state.Devices["device_a"] + d.ClassID = "class_b" + f.app.state.Devices[d.ID] = d + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "aA0_.:-"}, nil), 409) + moved := postStudentCall(t, f, "token-a", "moved") + if moved.ClassID != "class_b" || item.ClassID != "class_a" { + t.Fatal("binding not authoritative") + } + d.ClassID = "class_a" + f.app.state.Devices[d.ID] = d + for _, mode := range []string{"expired", "revoked", "released", "missing", "wrong_class"} { + original := f.app.state.Codes["CODE-A"] + code := original + switch mode { + case "expired": + expired := stamp(time.Now().Add(-time.Hour)) + code.ExpiresAt = &expired + case "revoked": + code.Status = LicenseRevoked + case "released": + code.Status = LicenseReleased + case "wrong_class": + other := "class_b" + code.ClassID = &other + } + f.app.state.Codes["CODE-A"] = code + if mode == "missing" { + delete(f.app.state.Codes, "CODE-A") + } + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "aA0_.:-"}, nil), 403) + f.app.state.Codes["CODE-A"] = original + } + delete(f.app.state.Devices, "device_a") + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "aA0_.:-"}, nil), 401) +} + +func TestStudentCallsClassWideCooldownAndRollback(t *testing.T) { + f := newSecurityFixture(t) + p := callPeer(t, f.app, "owner_a") + restore := closeStoreForRollback(t, f.app) + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "retry"}, nil), 500) + if len(f.app.state.StudentCalls) != 0 || len(p.queue) != 0 { + t.Fatal("failed create leaked state/event") + } + restore() + item := postStudentCall(t, f, "token-a", "retry") + takeCallFrame(t, p, item, "created") + // A second device shares the class cooldown, not its own separate timer. + f.app.state.Devices["device_a2"] = Device{ID: "device_a2", ClassID: "class_a", Name: "second", TokenHash: hash("token-a2")} + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a2", map[string]string{"request_id": "retry"}, nil), 409) + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a2", map[string]string{"request_id": "second"}, nil), 429) + postStudentCall(t, f, "token-b", "other-class") + restore = closeStoreForRollback(t, f.app) + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("owner"), nil, nil), 500) + if f.app.state.StudentCalls[item.ID].ReadAt != "" || len(p.queue) != 0 { + t.Fatal("failed read consumed state/event") + } + restore() + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 200) + takeCallFrame(t, p, item, "read") + old := f.app.state.StudentCalls[item.ID] + old.CreatedAt = stamp(time.Now().Add(-31 * time.Second)) + f.app.state.StudentCalls[item.ID] = old + postStudentCall(t, f, "token-a2", "second") +} + +func TestStudentCallsConcurrentReadAndCreate(t *testing.T) { + f := newSecurityFixture(t) + var wg sync.WaitGroup + ids := make(chan string, 12) + for i := 0; i < 12; i++ { + wg.Go(func() { + w := f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "concurrent"}, nil) + expectStatus(t, w, 200) + var item StudentCall + _ = json.Unmarshal(w.Body.Bytes(), &item) + ids <- item.ID + }) + } + wg.Wait() + close(ids) + var id string + for got := range ids { + if id != "" && got != id { + t.Fatal("duplicate request created different records") + } + id = got + } + if len(f.app.state.StudentCalls) != 1 { + t.Fatal("duplicate records") + } + reads := make(chan StudentCall, 12) + for i := 0; i < 12; i++ { + who := "owner" + if i%2 == 0 { + who = "collab" + } + wg.Go(func() { + w := f.request(t, "POST", studentCallsPath+"/"+id+"/read", f.token(who), nil, nil) + expectStatus(t, w, 200) + var item StudentCall + _ = json.Unmarshal(w.Body.Bytes(), &item) + reads <- item + }) + } + wg.Wait() + close(reads) + first := f.app.state.StudentCalls[id].StudentCall + for got := range reads { + if got != first { + t.Fatal("concurrent read overwrote first processor") + } + } + if first.ReadAt == "" || first.ReadBy == "" { + t.Fatal("not processed") + } +} + +func TestStudentCallsBroadcastRechecksCurrentAccess(t *testing.T) { + f := newSecurityFixture(t) + item := postStudentCall(t, f, "token-a", "pending-event") + owner := callPeer(t, f.app, "owner_a") + collab := callPeer(t, f.app, "collab_a") + outsider := callPeer(t, f.app, "outsider") + f.app.studentCallChanged(f.app.state.StudentCalls[item.ID], "read") + jobs := f.app.jobs + f.app.jobs = nil + delete(f.app.state.Members, memberKey("class_a", "collab_a")) + u := f.app.state.Users["owner_a"] + u.Status = AccountDisabled + f.app.state.Users[u.ID] = u + for _, job := range jobs { + job() + } + for _, p := range []*Peer{owner, collab, outsider} { + if len(p.queue) != 0 { + t.Fatal("lost/absent permission leaked broadcast") + } + } + expectStatus(t, f.request(t, "GET", studentCallsPath, f.token("owner"), nil, nil), 401) + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 403) + f.app.studentCallChanged(f.app.state.StudentCalls[item.ID], "created") + jobs = f.app.jobs + f.app.jobs = nil + delete(f.app.state.StudentCalls, item.ID) + for _, job := range jobs { + job() + } + if len(collab.queue) != 0 { + t.Fatal("deleted call event leaked") + } +} + +func TestStudentCallsPaginationCountsAndStableTimes(t *testing.T) { + f := newSecurityFixture(t) + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 105; i++ { + id := fmt.Sprintf("call_%03d", i) + f.app.state.StudentCalls[id] = studentCallRecord{StudentCall: StudentCall{ID: id, ClassID: "class_a", ClassName: "甲班", DeviceID: "device_a", DeviceName: "甲设备", CreatedAt: stamp(at.Add(time.Duration(i/2) * time.Second))}, RequestID: id} + } + first := listStudentCall(t, f, "owner", "") + if len(first.Items) != 100 || first.UnreadCount != 105 || first.NextCursor == nil { + t.Fatal("default window must not truncate unread count") + } + second := listStudentCall(t, f, "owner", "?cursor="+*first.NextCursor) + if len(second.Items) != 5 || second.UnreadCount != 105 || second.NextCursor != nil { + t.Fatal("wrong next page") + } + all := append(first.Items, second.Items...) + seen := map[string]bool{} + for _, item := range all { + if seen[item.ID] { + t.Fatal("cursor duplicated tied timestamp") + } + seen[item.ID] = true + } + for _, suffix := range []string{"?limit=0", "?limit=101", "?limit=oops", "?limit=", "?limit=1&limit=2", "?cursor=bad", "?cursor=" + base64.RawURLEncoding.EncodeToString([]byte(`{"created_at":"invalid","id":"x"}`))} { + expectStatus(t, f.request(t, "GET", studentCallsPath+suffix, f.token("owner"), nil, nil), 400) + } + fractional := StudentCall{ID: "frac", CreatedAt: "2026-01-01T00:00:00.1Z"} + whole := StudentCall{ID: "whole", CreatedAt: "2026-01-01T00:00:00Z"} + if compareStudentCalls(fractional, whole) >= 0 { + t.Fatal("fractional timestamp sorted before whole second") + } + if got := listStudentCall(t, f, "school", ""); got.UnreadCount != 0 { + t.Fatal("admin got school count") + } +} + +func TestStudentCallsPersistRestartAndClassDeletion(t *testing.T) { + seed := newSecurityFixture(t) + config := Config{DataDir: t.TempDir(), JWTSecret: strings.Repeat("r", 32)} + a, err := New(config) + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(seed.app.state) + if err = json.Unmarshal(data, &a.state); err != nil { + t.Fatal(err) + } + f := &securityFixture{app: a, users: seed.users} + item := postStudentCall(t, f, "token-a", "restart") + if err = a.Close(); err != nil { + t.Fatal(err) + } + a, err = New(config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = a.Close() }) + f.app = a + if replay := postStudentCall(t, f, "token-a", "restart"); replay != item { + t.Fatal("restart lost request dedupe") + } + expectStatus(t, f.request(t, "POST", deviceCallsPath, "token-a", map[string]string{"request_id": "restart-new"}, nil), 429) + if got := listStudentCall(t, f, "owner", ""); got.UnreadCount != 1 { + t.Fatal("offline records lost") + } + expectStatus(t, f.request(t, "POST", studentCallsPath+"/"+item.ID+"/read", f.token("collab"), nil, nil), 200) + var persisted State + if err = a.store.Load(context.Background(), &persisted); err != nil || persisted.StudentCalls[item.ID].ReadBy != "collab_a" { + t.Fatal("shared read not durable", err) + } + expectStatus(t, f.request(t, "DELETE", "/api/v1/classes/class_a", f.token("owner"), nil, nil), 200) + if len(a.state.StudentCalls) != 0 { + t.Fatal("class deletion left call records") + } + persisted = State{} + if err = a.store.Load(context.Background(), &persisted); err != nil || len(persisted.StudentCalls) != 0 { + t.Fatal("class deletion not durable", err) + } +} diff --git a/server/internal/repo/store.go b/server/internal/repo/store.go index 6dd35c4..bcdb00e 100644 --- a/server/internal/repo/store.go +++ b/server/internal/repo/store.go @@ -11,7 +11,7 @@ import ( "path/filepath" ) -var tables = []string{"users", "classes", "class_members", "devices", "messages", "homeworks", "activation_codes", "collab_invites", "sessions", "phrases", "audio", "audit_logs", "settings", "schools"} +var tables = []string{"users", "classes", "class_members", "devices", "messages", "homeworks", "student_calls", "activation_codes", "collab_invites", "sessions", "phrases", "audio", "audit_logs", "settings", "schools"} type Store struct { db *sql.DB @@ -44,7 +44,7 @@ func Open(path string) (*Store, error) { } s.saved[table] = map[string]string{} } - for _, q := range []string{`CREATE UNIQUE INDEX IF NOT EXISTS users_username ON users(json_extract(body,'$.username'))`, `CREATE INDEX IF NOT EXISTS classes_org ON classes(json_extract(body,'$.org_id'))`, `CREATE INDEX IF NOT EXISTS devices_class ON devices(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS messages_created ON messages(json_extract(body,'$.created_at'))`, `CREATE INDEX IF NOT EXISTS homeworks_class ON homeworks(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS members_class ON class_members(json_extract(body,'$.class_id'))`} { + for _, q := range []string{`CREATE UNIQUE INDEX IF NOT EXISTS users_username ON users(json_extract(body,'$.username'))`, `CREATE INDEX IF NOT EXISTS classes_org ON classes(json_extract(body,'$.org_id'))`, `CREATE INDEX IF NOT EXISTS devices_class ON devices(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS messages_created ON messages(json_extract(body,'$.created_at'))`, `CREATE UNIQUE INDEX IF NOT EXISTS student_calls_request ON student_calls(json_extract(body,'$.request_id'))`, `CREATE INDEX IF NOT EXISTS student_calls_class ON student_calls(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS homeworks_class ON homeworks(json_extract(body,'$.class_id'))`, `CREATE INDEX IF NOT EXISTS members_class ON class_members(json_extract(body,'$.class_id'))`} { if _, err = db.Exec(q); err != nil { db.Close() return nil, err diff --git a/tests/e2e/homework.spec.ts b/tests/e2e/homework.spec.ts index 0895bf1..41f1c47 100644 --- a/tests/e2e/homework.spec.ts +++ b/tests/e2e/homework.spec.ts @@ -90,10 +90,13 @@ test('教师发布、修改、切换显示设置并删除作业,布局适应 return { top: rect.top, left: rect.left, right: rect.right }; }), ); - expect(positions).toHaveLength(5); - expect(new Set(positions.map((position) => position.top)).size).toBe(1); - for (let index = 1; index < positions.length; index++) - expect(positions[index].left).toBeGreaterThanOrEqual(positions[index - 1].right - 1); + expect(positions).toHaveLength(6); + expect(new Set(positions.map((position) => position.top)).size).toBe(2); + for (const row of [positions.slice(0, 3), positions.slice(3)]) { + expect(new Set(row.map((position) => position.top)).size).toBe(1); + for (let index = 1; index < row.length; index++) + expect(row[index].left).toBeGreaterThanOrEqual(row[index - 1].right - 1); + } } await expect(card).toBeVisible(); await page.screenshot({ path: info.outputPath(`homework-${width}-${scheme}.png`), fullPage: true }); diff --git a/tests/e2e/student-calls-integration.spec.ts b/tests/e2e/student-calls-integration.spec.ts new file mode 100644 index 0000000..7e0631e --- /dev/null +++ b/tests/e2e/student-calls-integration.spec.ts @@ -0,0 +1,162 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; + +const apiURL = 'http://127.0.0.1:18080'; +const teacherURL = 'http://127.0.0.1:15173'; +const password = 'StudentCallFixture2026'; +const auth = (token: string) => ({ Authorization: 'Bearer ' + token }); + +async function signIn(api: APIRequestContext, username: string, secret = password, scope = 'teacher') { + const response = await api.post('/api/v1/auth/login', { + headers: { 'X-ClassDing-Auth-Scope': scope }, + data: { username, password: secret }, + }); + expect(response.status()).toBe(200); + return (await response.json()).access_token as string; +} +async function login(page: Page, username: string) { + await page.goto(teacherURL + '/login'); + await page.getByLabel('用户名', { exact: true }).fill(username); + await page.getByLabel('密码', { exact: true }).fill(password); + await page.getByRole('button', { name: '进入工作台' }).click(); + await expect(page.getByText('实时连接已开启')).toBeVisible(); +} + +test('真实设备呼叫向两位老师实时送达,打开不自动已读且共享处理持久保存', async ({ + page, + browser, + playwright, +}, info) => { + const api = await playwright.request.newContext({ baseURL: apiURL }); + const otherContext = await browser.newContext({ viewport: info.project.use.viewport }); + const other = await otherContext.newPage(); + const screen = await otherContext.newPage(); + const suffix = Date.now().toString(36) + (info.project.name === 'mobile' ? 'm' : 'd'); + const ownerName = 'call.owner.' + suffix; + const colleagueName = 'call.peer.' + suffix; + let classId = ''; + const userIds: string[] = []; + let adminToken = ''; + try { + adminToken = await signIn(api, 'admin', 'AdminDing2026!', 'admin'); + for (const [username, display_name] of [ + [ownerName, '呼叫班主任'], + [colleagueName, '呼叫任课老师'], + ]) { + const response = await api.post('/api/v1/admin/users', { + headers: auth(adminToken), + data: { username, password, display_name }, + }); + expect(response.status()).toBe(200); + userIds.push((await response.json()).id); + } + const createdClass = await api.post('/api/v1/admin/classes', { + headers: auth(adminToken), + data: { name: '呼叫验收班' + suffix, owner_id: userIds[0], max_teachers: 2, max_devices: 1 }, + }); + expect(createdClass.status()).toBe(200); + classId = (await createdClass.json()).id; + const ownerToken = await signIn(api, ownerName); + const colleagueToken = await signIn(api, colleagueName); + const invite = await api.post('/api/v1/classes/' + classId + '/invites', { headers: auth(ownerToken) }); + expect(invite.status()).toBe(200); + const joined = await api.post('/api/v1/classes/join', { + headers: auth(colleagueToken), + data: { code: (await invite.json()).code }, + }); + expect(joined.status()).toBe(200); + const bindCode = await api.post('/api/v1/classes/' + classId + '/bind-code', { + headers: auth(ownerToken), + }); + expect(bindCode.status()).toBe(200); + await screen.goto('http://127.0.0.1:15175'); + await screen.getByLabel('6 位班级绑定码').fill((await bindCode.json()).code); + await screen.getByRole('button', { name: '设备与服务器设置', exact: true }).click(); + await screen.getByLabel('设备名称', { exact: true }).fill('呼叫验收大屏'); + await screen.getByRole('button', { name: '连接班级', exact: true }).click(); + await expect(screen.getByText('连接正常', { exact: true })).toBeVisible(); + await login(page, ownerName); + await login(other, colleagueName); + let callRequests = 0; + screen.on('request', (request) => { + if (request.url().endsWith('/api/v1/device/student-calls') && request.method() === 'POST') + callRequests++; + }); + await screen.getByRole('button', { name: '呼叫老师', exact: true }).click(); + const confirmation = screen.getByRole('dialog'); + await expect(confirmation.getByText('确认呼叫老师?', { exact: true })).toBeVisible(); + await screen.screenshot({ path: info.outputPath('student-call-confirmation.png'), fullPage: true }); + await confirmation.getByRole('button', { name: '取消', exact: true }).click(); + await expect(confirmation).toBeHidden(); + expect(callRequests).toBe(0); + const afterCancel = await api.get('/api/v1/student-calls', { headers: auth(ownerToken) }); + expect(await afterCancel.json()).toMatchObject({ items: [], unread_count: 0 }); + await screen.getByRole('button', { name: '呼叫老师', exact: true }).click(); + const sent = screen.waitForResponse( + (response) => + response.url().endsWith('/api/v1/device/student-calls') && response.request().method() === 'POST', + ); + await confirmation.getByRole('button', { name: '确认呼叫', exact: true }).click(); + const response = await sent; + expect(response.status()).toBe(200); + expect(callRequests).toBe(1); + await expect(confirmation.getByRole('status')).toContainText('呼叫已发送'); + const requestId = response.request().postDataJSON().request_id as string; + const deviceAuthorization = response.request().headers()['authorization']; + const send = () => + api.post('/api/v1/device/student-calls', { + headers: { Authorization: deviceAuthorization }, + data: { request_id: requestId }, + }); + const call = await response.json(); + expect((await (await send()).json()).id).toBe(call.id); + const tooSoon = await api.post('/api/v1/device/student-calls', { + headers: { Authorization: deviceAuthorization }, + data: { request_id: requestId + '-new' }, + }); + expect(tooSoon.status()).toBe(429); + expect(Number(tooSoon.headers()['retry-after'])).toBeGreaterThan(0); + for (const teacher of [page, other]) { + const entry = teacher + .getByRole('link', { name: '学生呼叫,1 条未读', exact: true }) + .filter({ visible: true }); + await expect(entry).toBeVisible({ timeout: 8000 }); + await entry.click(); + await expect(teacher.getByRole('heading', { name: '学生呼叫', exact: true })).toBeVisible(); + await expect(teacher.locator('[data-call-id="' + call.id + '"]')).toContainText('呼叫验收大屏'); + await expect(teacher.getByRole('button', { name: '标记已读' })).toBeVisible(); + } + // Viewing and reloading must never mutate shared unread state. + await page.reload(); + await expect(page.getByRole('button', { name: '标记已读' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('student-call-unread.png'), fullPage: true }); + const before = await api.get('/api/v1/student-calls', { headers: auth(ownerToken) }); + expect((await before.json()).unread_count).toBe(1); + await other.getByRole('button', { name: '标记已读' }).click(); + for (const teacher of [page, other]) { + const card = teacher.locator('[data-call-id="' + call.id + '"]'); + await expect(card).toContainText('呼叫任课老师 已处理', { timeout: 8000 }); + await expect(card.getByRole('button', { name: '标记已读' })).toHaveCount(0); + await expect(teacher.getByText('0 条未读', { exact: true })).toBeVisible(); + } + await page.reload(); + await expect(page.locator('[data-call-id="' + call.id + '"]')).toContainText('呼叫任课老师 已处理'); + const saved = await api.get('/api/v1/student-calls', { headers: auth(colleagueToken) }); + expect(await saved.json()).toMatchObject({ + unread_count: 0, + items: [{ id: call.id, read_by: userIds[1] }], + }); + } finally { + await otherContext.close(); + if (adminToken && classId) { + expect( + (await api.delete('/api/v1/admin/classes/' + classId, { headers: auth(adminToken) })).status(), + ).toBe(200); + } + for (const id of userIds) { + expect((await api.delete('/api/v1/admin/users/' + id, { headers: auth(adminToken) })).status()).toBe( + 200, + ); + } + await api.dispose(); + } +}); diff --git a/tests/e2e/student-calls.spec.ts b/tests/e2e/student-calls.spec.ts new file mode 100644 index 0000000..d35383d --- /dev/null +++ b/tests/e2e/student-calls.spec.ts @@ -0,0 +1,218 @@ +import { expect, test, type Page, type WebSocketRoute } from '@playwright/test'; +import type { StudentCall } from '../../web/apps/teacher/src/models'; + +const teacherURL = 'http://127.0.0.1:15173'; +const call = (id = 'call-1'): StudentCall => ({ + id, + class_id: 'class-1', + class_name: '一年级(1)班', + device_id: 'device-1', + device_name: '教室大屏', + created_at: '2026-11-13T01:00:00Z', + read_at: '', + read_by: '', + read_by_name: '', +}); + +async function mockCalls(page: Page, initial = [call()]) { + const state = { + items: initial, + reads: 0, + gets: 0, + legacy: false, + account: 'teacher-a', + loggedIn: true, + cursor: false, + }; + const sockets = new Set(); + await page.routeWebSocket('**/ws/teacher', (socket) => { + sockets.add(socket); + socket.onMessage((message) => { + const frame = JSON.parse(String(message)); + if (frame.t === 'hello') socket.send(JSON.stringify({ t: 'welcome', d: {} })); + if (frame.t === 'ping') socket.send(JSON.stringify({ t: 'pong', d: {} })); + }); + socket.onClose(() => sockets.delete(socket)); + }); + await page.route('**/api/v1/**', async (route) => { + const path = new URL(route.request().url()).pathname.replace('/api/v1', ''); + const user = { id: state.account, username: state.account, display_name: '测试老师', ui_scale: 1 }; + if (path === '/auth/logout') { + state.loggedIn = false; + return route.fulfill({ json: { ok: true } }); + } + if (path === '/auth/login') { + state.loggedIn = true; + return route.fulfill({ json: { access_token: state.account, user } }); + } + if (path === '/auth/refresh' || path === '/me') + return route.fulfill( + state.loggedIn + ? { json: path === '/me' ? user : { access_token: state.account, user } } + : { status: 401, json: { code: 'UNAUTHORIZED', message: '请登录' } }, + ); + if (path === '/student-calls') { + state.gets++; + if (state.legacy) + return route.fulfill({ status: 404, json: { code: 'NOT_FOUND', message: 'unknown endpoint' } }); + const later = new URL(route.request().url()).searchParams.has('cursor'); + return route.fulfill({ + json: { + items: state.cursor ? state.items.slice(later ? 1 : 0, later ? undefined : 1) : state.items, + unread_count: state.items.filter((item) => !item.read_at).length, + next_cursor: state.cursor && !later ? 'older-page' : null, + }, + }); + } + if (path.endsWith('/read')) { + state.reads++; + const id = path.split('/')[2]; + state.items = state.items.map((item) => + item.id !== id || item.read_at + ? item + : { + ...item, + read_at: '2026-11-13T01:02:00Z', + read_by: 'teacher-b', + read_by_name: '李老师', + }, + ); + return route.fulfill({ json: state.items.find((item) => item.id === id) }); + } + if (path === '/public/site') return route.fulfill({ json: {} }); + return route.fulfill({ json: [] }); + }); + const emit = (kind: 'created' | 'read', id = 'call-1') => { + for (const socket of sockets) + socket.send(JSON.stringify({ t: 'student_call', d: { id, class_id: 'class-1', kind } })); + }; + return { state, sockets, emit }; +} + +function navigation(page: Page) { + return page.locator((page.viewportSize()?.width ?? 1440) < 768 ? '.bottom-nav' : '.side-nav'); +} + +test('学生呼叫打开不自动已读,显式处理共享结果并刷新恢复,后页角标同步', async ({ page }) => { + const { state } = await mockCalls(page, [call(), call('call-2')]); + state.cursor = true; + await page.goto(`${teacherURL}/student-calls`); + const nav = navigation(page).locator('a[href="/student-calls"]'); + await expect(nav).toHaveAttribute('aria-label', '学生呼叫,2 条未读'); + await expect(page.locator('.student-call-card')).toHaveCount(1); + await expect(page.getByText('打开页面不会自动标记已读。', { exact: false })).toBeVisible(); + expect(state.reads).toBe(0); + await page.getByRole('button', { name: '加载更早的呼叫' }).click(); + const older = page.locator('[data-call-id="call-2"]'); + await expect(older).toBeVisible(); + expect(state.reads).toBe(0); + await older.getByRole('button', { name: '标记已读' }).click(); + await expect(older).toContainText('李老师 已处理'); + await expect(nav).toHaveAttribute('aria-label', '学生呼叫,1 条未读'); + expect(state.reads).toBe(1); + await page.reload(); + await page.getByRole('button', { name: '加载更早的呼叫' }).click(); + await expect(older).toContainText('已处理'); + await expect(older.getByRole('button', { name: '标记已读' })).toHaveCount(0); + expect(state.reads).toBe(1); +}); + +test('学生呼叫 WS 实时更新只提醒新增,处理后共享消角标,断线重连补查', async ({ page }) => { + const { state, sockets, emit } = await mockCalls(page, []); + await page.goto(`${teacherURL}/student-calls`); + await expect(page.getByText('实时连接已开启')).toBeVisible(); + state.items = [call()]; + emit('created'); + await expect(page.locator('.student-call-card')).toHaveCount(1); + await expect(page.locator('[data-sonner-toast]')).toContainText('一年级(1)班的学生正在呼叫老师'); + emit('created'); + await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); + state.items = [ + { ...call(), read_at: '2026-11-13T01:02:00Z', read_by: 'teacher-b', read_by_name: '李老师' }, + ]; + emit('read'); + await expect(page.locator('.student-call-card')).toContainText('李老师 已处理'); + await expect(navigation(page).locator('a[href="/student-calls"]')).toHaveAttribute( + 'aria-label', + '学生呼叫', + ); + expect(state.reads).toBe(0); + for (const socket of sockets) socket.close(); + state.items.unshift(call('missed-call')); + await expect(page.locator('[data-call-id="missed-call"]')).toBeVisible(); +}); + +test('学生呼叫旧服务404停止自动重试且不刷toast,手动刷新可恢复', async ({ page }) => { + const { state } = await mockCalls(page); + state.legacy = true; + await page.clock.install(); + await page.goto(`${teacherURL}/student-calls`); + await expect(page.getByText('当前服务暂未开放学生呼叫')).toBeVisible(); + const requests = state.gets; + await page.clock.fastForward(120000); + expect(state.gets).toBe(requests); + await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); + state.legacy = false; + await page.getByRole('button', { name: '刷新', exact: true }).click(); + await expect(page.locator('.student-call-card')).toHaveCount(1); +}); + +test('学生呼叫断开实时通道仍轮询补查', async ({ page }) => { + const { state, sockets } = await mockCalls(page, []); + await page.clock.install(); + await page.goto(`${teacherURL}/student-calls`); + await expect(page.getByText('还没有学生呼叫')).toBeVisible(); + for (const socket of sockets) socket.close(); + state.items = [call('polled-call')]; + await page.clock.fastForward(31000); + await expect(page.locator('[data-call-id="polled-call"]')).toBeVisible(); +}); + +test('学生呼叫手机六项导航两行三列,标准与大字号不横向拥挤', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 760 }); + await mockCalls(page); + await page.goto(`${teacherURL}/student-calls`); + await expect(page.locator('.student-call-card')).toHaveCount(1); + for (const size of [16, 24]) { + await page.evaluate((size) => { + document.documentElement.style.fontSize = `${size}px`; + }, size); + const links = page.locator('.bottom-nav a'); + await expect(links).toHaveCount(6); + const boxes = await links.evaluateAll((elements) => + elements.map((item) => { + const rect = item.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right }; + }), + ); + expect(boxes[0].y).toBe(boxes[2].y); + expect(boxes[3].y).toBe(boxes[5].y); + expect(boxes[3].y).toBeGreaterThan(boxes[0].y); + expect(boxes.every((box) => box.height >= 44 && box.width >= 80 && box.right <= 321)).toBe(true); + await expect(page.locator('.bottom-nav a[href="/student-calls"]')).toContainText('学生呼叫'); + await page.getByRole('button', { name: '标记已读' }).scrollIntoViewIfNeeded(); + await expect(page.getByRole('button', { name: '标记已读' })).toBeInViewport(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); + } +}); + +test('学生呼叫退出换账号不泄漏列表角标和提醒', async ({ page }) => { + const { state, emit } = await mockCalls(page); + await page.goto(`${teacherURL}/student-calls`); + await expect(page.locator('.student-call-card')).toHaveCount(1); + emit('created'); + await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); + await navigation(page).locator('a[href="/me"]').click(); + await page.getByRole('button', { name: '退出当前账号' }).click(); + await expect(page.getByRole('button', { name: '进入工作台' })).toBeVisible(); + await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); + state.account = 'teacher-c'; + state.items = []; + await page.getByLabel('用户名', { exact: true }).fill('teacher-c'); + await page.getByLabel('密码', { exact: true }).fill('test-password'); + await page.getByRole('button', { name: '进入工作台' }).click(); + await navigation(page).locator('a[href="/student-calls"]').click(); + await expect(page.getByText('还没有学生呼叫')).toBeVisible(); + await expect(page.locator('.student-call-unread')).toHaveCount(0); + await expect(page.locator('.student-call-card')).toHaveCount(0); +}); diff --git a/web/apps/admin/package.json b/web/apps/admin/package.json index d7431fe..9d4fc87 100644 --- a/web/apps/admin/package.json +++ b/web/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@classding/admin", - "version": "0.3.4", + "version": "0.3.5", "private": true, "type": "module", "scripts": { diff --git a/web/apps/teacher/README.md b/web/apps/teacher/README.md index 4b66b38..efb274b 100644 --- a/web/apps/teacher/README.md +++ b/web/apps/teacher/README.md @@ -25,6 +25,7 @@ pnpm dev - 可向有权限的班级发送文字或上传原声,显示预览、发送记录及设备回执。离线消息只在有效期内等待补发。 - 录音依赖浏览器麦克风权限,最长 30 秒;当前发送原声,不提供服务端语音转文字。文本语音由大屏使用可用的系统语音播报。 +- 「学生呼叫」位于「发通知」之后,显示所有关联班级的呼叫记录和红色未读数量;打开页面不会自动已读。任一关联老师点击「标记已读」后共享为已处理,保留首位处理人和时间;记录在刷新、重新登录和服务重启后保留。网页在线时实时提醒,并在重连或定时查询时补齐,不提供 Web Push 或关闭浏览器后的系统推送。 - 页面有桌面和移动布局,但录音权限、微信内置浏览器及目标手机的显示和交互仍需实机验证。 检查与构建: diff --git a/web/apps/teacher/package.json b/web/apps/teacher/package.json index 2379144..37df895 100644 --- a/web/apps/teacher/package.json +++ b/web/apps/teacher/package.json @@ -1,6 +1,6 @@ { "name": "@classding/teacher", - "version": "0.3.4", + "version": "0.3.5", "private": true, "type": "module", "scripts": { diff --git a/web/apps/teacher/src/App.tsx b/web/apps/teacher/src/App.tsx index 0cf5941..cbcd6ab 100644 --- a/web/apps/teacher/src/App.tsx +++ b/web/apps/teacher/src/App.tsx @@ -1,6 +1,16 @@ import { useState } from 'react'; import { Navigate, NavLink, Outlet, Route, Routes, useLocation } from 'react-router'; -import { BookOpenCheck, CircleHelp, History, LayoutGrid, LogOut, Radio, Send, UserRound } from 'lucide-react'; +import { + BellRing, + BookOpenCheck, + CircleHelp, + History, + LayoutGrid, + LogOut, + Radio, + Send, + UserRound, +} from 'lucide-react'; import { Avatar, AvatarFallback } from '@classding/ui/components/ui/avatar'; import { Badge } from '@classding/ui/components/ui/badge'; import { @@ -58,10 +68,13 @@ import HistoryPage from './pages/History'; import MePage from './pages/Me'; import LoginPage from './pages/Login'; import HomeworkPage from './pages/Homework'; +import StudentCallsPage from './pages/StudentCalls'; +import { useStudentCalls } from './studentCalls'; import { APP_VERSION } from '@classding/ui/version'; const links = [ { to: '/send', icon: Send, label: '发通知', short: '发通知' }, + { to: '/student-calls', icon: BellRing, label: '学生呼叫', short: '学生呼叫' }, { to: '/classes', icon: LayoutGrid, label: '我的班级', short: '班级' }, { to: '/homework', icon: BookOpenCheck, label: '布置作业', short: '作业' }, { to: '/history', icon: History, label: '发送记录', short: '记录' }, @@ -106,6 +119,17 @@ function HelpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open function Layout() { const { user, logout, updateUser } = useAuth(); const online = useTeacherSocket(); + const calls = useStudentCalls(user?.id ?? ''); + const unread = calls.data?.pages[0]?.unread_count ?? 0; + const callLabel = unread ? `学生呼叫,${unread} 条未读` : '学生呼叫'; + const callBadge = unread > 0 && ( + + {unread > 99 ? '99+' : unread} + + ); function changeScale(scale: number) { if (!user) return; const previous = user; @@ -142,10 +166,13 @@ function Layout() { } + render={} > {label} + {to === '/student-calls' && ( + {callBadge} + )} ))} @@ -186,32 +213,36 @@ function Layout() { - +
- + 工作台 - + {active.label} -
- +
+ - {online ? '实时连接已开启' : '正在连接实时通道'} + {online ? '实时连接已开启' : '正在连接实时通道'} @@ -227,21 +258,25 @@ function Layout() {
); - return user ? : ; + return user ? : ; } export default function App() { @@ -274,6 +309,7 @@ export default function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/web/apps/teacher/src/auth.tsx b/web/apps/teacher/src/auth.tsx index 2c4e85a..330820c 100644 --- a/web/apps/teacher/src/auth.tsx +++ b/web/apps/teacher/src/auth.tsx @@ -30,6 +30,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { revision.current++; setUser(null); cache.clear(); + toast.dismiss(); toast.error('登录状态已变化,请重新登录'); }), [cache], @@ -56,6 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAccessToken(response.access_token, response.user.id); setUser(response.user); cache.clear(); + toast.dismiss(); setReady(true); } async function logout() { @@ -65,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); setReady(true); cache.clear(); + toast.dismiss(); try { await request; } catch { diff --git a/web/apps/teacher/src/hooks.ts b/web/apps/teacher/src/hooks.ts index 83c6e54..22630de 100644 --- a/web/apps/teacher/src/hooks.ts +++ b/web/apps/teacher/src/hooks.ts @@ -1,7 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api, getAccessToken } from '@classding/api'; -import type { Class, Homework, MessagePage, Phrase } from './models'; +import type { Class, Homework, MessagePage, Phrase, StudentCallPage } from './models'; +import type { InfiniteData } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useAuth } from './auth'; +import { readStudentCallEvent, studentCallsKey, studentCallsUnavailable } from './studentCalls'; export const useClasses = () => useQuery({ queryKey: ['classes'], queryFn: () => api('/classes') }); export const useHomeworks = (classId: string) => useQuery({ @@ -19,9 +23,14 @@ export const useMessages = (classId = '') => }); export function useTeacherSocket() { const cache = useQueryClient(); + const { user } = useAuth(); + const userId = user?.id ?? ''; const [online, setOnline] = useState(false); useEffect(() => { + if (!userId) return; + setOnline(false); let stopped = false; + const announced = new Set(); let socket: WebSocket | undefined; let timeout: ReturnType; let ping: ReturnType; @@ -29,14 +38,17 @@ export function useTeacherSocket() { let lastFrame = Date.now(); function connect() { if (stopped) return; - socket = new WebSocket( + const current = new WebSocket( `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/teacher`, ); - socket.onopen = () => { + socket = current; + current.onopen = () => { + if (stopped || socket !== current) return; lastFrame = Date.now(); - socket?.send(JSON.stringify({ t: 'hello', d: { token: getAccessToken() } })); + current.send(JSON.stringify({ t: 'hello', d: { token: getAccessToken() } })); }; - socket.onmessage = (event) => { + current.onmessage = (event) => { + if (stopped || socket !== current) return; lastFrame = Date.now(); let frame; try { @@ -48,6 +60,8 @@ export function useTeacherSocket() { attempts = 0; setOnline(true); void cache.invalidateQueries({ queryKey: ['homeworks'] }); + if (!studentCallsUnavailable(cache.getQueryState(studentCallsKey(userId))?.error)) + void cache.invalidateQueries({ queryKey: studentCallsKey(userId) }); clearInterval(ping); ping = setInterval(() => { if (Date.now() - lastFrame > 60000) { @@ -57,6 +71,21 @@ export function useTeacherSocket() { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ t: 'ping', d: {} })); }, 25000); } + if (frame.t === 'student_call') { + const call = readStudentCallEvent(frame.d); + if (!call) return; + void cache.invalidateQueries({ queryKey: studentCallsKey(userId) }).then(() => { + if (stopped || socket !== current || call.kind !== 'created' || announced.has(call.id)) return; + announced.add(call.id); + if (announced.size > 512) announced.delete(announced.values().next().value!); + const data = cache.getQueryData>(studentCallsKey(userId)); + const item = data?.pages.flatMap((page) => page.items).find((item) => item.id === call.id); + toast.info(item ? `${item.class_name}的学生正在呼叫老师` : '收到新的学生呼叫', { + id: `student-call-${userId}-${call.id}`, + description: '请在“学生呼叫”中查看,处理后标记已读。', + }); + }); + } if (frame.t === 'receipt') void cache.invalidateQueries({ queryKey: ['messages'] }); if (frame.t === 'homework') void cache.invalidateQueries({ queryKey: ['homeworks'] }); if (frame.t === 'presence') { @@ -64,7 +93,8 @@ export function useTeacherSocket() { void cache.invalidateQueries({ queryKey: ['devices'] }); } }; - socket.onclose = () => { + current.onclose = () => { + if (stopped || socket !== current) return; setOnline(false); clearInterval(ping); if (!stopped) { @@ -79,7 +109,7 @@ export function useTeacherSocket() { }); } }; - socket.onerror = () => socket?.close(); + current.onerror = () => current.close(); } connect(); return () => { @@ -88,7 +118,7 @@ export function useTeacherSocket() { clearInterval(ping); socket?.close(); }; - }, [cache]); + }, [cache, userId]); return online; } export function useNow(interval = 1000) { diff --git a/web/apps/teacher/src/models.ts b/web/apps/teacher/src/models.ts index e97c41a..71fa6ab 100644 --- a/web/apps/teacher/src/models.ts +++ b/web/apps/teacher/src/models.ts @@ -11,3 +11,6 @@ export type Phrase = Schemas['Phrase']; export type Homework = Schemas['Homework']; export type AuthResponse = Schemas['AuthResponse']; export type BindCode = Schemas['BindCode']; + +export type StudentCall = Schemas['StudentCall']; +export type StudentCallPage = Schemas['StudentCallList']; diff --git a/web/apps/teacher/src/pages/StudentCalls.tsx b/web/apps/teacher/src/pages/StudentCalls.tsx new file mode 100644 index 0000000..11fa016 --- /dev/null +++ b/web/apps/teacher/src/pages/StudentCalls.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef } from 'react'; +import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query'; +import { api } from '@classding/api'; +import { BellRing, Check, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import { Alert, AlertDescription, AlertTitle } from '@classding/ui/components/ui/alert'; +import { Badge } from '@classding/ui/components/ui/badge'; +import { Button } from '@classding/ui/components/ui/button'; +import { Card, CardContent } from '@classding/ui/components/ui/card'; +import { Spinner } from '@classding/ui/components/ui/spinner'; +import { useAuth } from '../auth'; +import { EmptyState, ErrorState, Loading, PageHeader, formatTime } from '../components'; +import type { StudentCall, StudentCallPage } from '../models'; +import { + applyStudentCallRead, + studentCallsKey, + studentCallsUnavailable, + useStudentCalls, +} from '../studentCalls'; + +export default function StudentCallsPage() { + const { user } = useAuth(); + const userId = user?.id ?? ''; + const currentUser = useRef(userId); + currentUser.current = userId; + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const cache = useQueryClient(); + const query = useStudentCalls(userId); + const pages = query.data?.pages ?? []; + const items = [...new Map(pages.flatMap((page) => page.items).map((item) => [item.id, item])).values()]; + const unread = pages[0]?.unread_count ?? 0; + const markRead = useMutation({ + mutationFn: ({ id }: { id: string; account: string }) => + api(`/student-calls/${encodeURIComponent(id)}/read`, { method: 'POST' }), + onSuccess: (call, { account }) => { + if (!mounted.current || currentUser.current !== account) return; + cache.setQueryData>(studentCallsKey(account), (data) => + applyStudentCallRead(data, call), + ); + void cache.invalidateQueries({ queryKey: studentCallsKey(account) }); + toast.success('已标记已读,所有关联老师共享此处理状态'); + }, + onError: (error, { account }) => { + if (mounted.current && currentUser.current === account) toast.error(error.message); + }, + }); + const refresh = () => { + if (!query.isFetching) void query.refetch(); + }; + return ( +
+ +
+
+ {unread} 条未读 + 标记已读即表示已处理 +
+ +
+ {query.isError && query.data && !studentCallsUnavailable(query.error) && ( + + + 暂时无法同步最新呼叫 + + 以下保留上次获取的记录和未读数量,连接恢复后会自动重试。也可点击刷新。 + + + )} + {studentCallsUnavailable(query.error) ? ( + + + 当前服务暂未开放学生呼叫 + 请等待服务升级后再试,其他功能不受影响。 + + ) : query.isPending ? ( + + ) : query.isError && !query.data ? ( + + ) : items.length === 0 ? ( + } + title="还没有学生呼叫" + description="学生在教室端确认呼叫后,记录会保存在这里。网页在线时会实时提醒。" + /> + ) : ( +
    + {items.map((item) => ( +
  • + + +
    +
    +

    {item.class_name}

    + + {item.read_at ? '已处理' : '未读'} + +
    +

    + {item.device_name || '教室设备'} · 呼叫于 {formatTime(item.created_at)} +

    + {item.read_at ? ( +

    + {item.read_by_name || '关联老师'} 已处理 · {formatTime(item.read_at)} +

    + ) : ( +

    任一老师处理后,所有老师同步为已读。

    + )} +
    + {!item.read_at && ( + + )} +
    +
    +
  • + ))} +
+ )} + {query.hasNextPage && !query.isError && ( + + )} +

+ 共享已读不会删除记录。仅在网页打开且联网时实时提醒,不发送系统推送。 +

+
+ ); +} diff --git a/web/apps/teacher/src/studentCalls.test.ts b/web/apps/teacher/src/studentCalls.test.ts new file mode 100644 index 0000000..c7fd566 --- /dev/null +++ b/web/apps/teacher/src/studentCalls.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { applyStudentCallRead, readStudentCallEvent, studentCallsKey } from './studentCalls'; +import type { StudentCall, StudentCallPage } from './models'; + +const call: StudentCall = { + id: 'call-1', + class_id: 'class-1', + class_name: '一年级', + device_id: 'device-1', + device_name: '教室', + created_at: '2026-01-01T01:00:00Z', + read_at: '', + read_by: '', + read_by_name: '', +}; +const handled: StudentCall = { + ...call, + read_at: '2026-01-01T01:01:00Z', + read_by: 'teacher-2', + read_by_name: '李老师', +}; +const page = (items: StudentCall[], count: number): StudentCallPage => ({ + items, + unread_count: count, + next_cursor: null, +}); + +describe('student calls shared state', () => { + it('scopes cache keys by account', () => { + expect(studentCallsKey('a')).not.toEqual(studentCallsKey('b')); + }); + it('parses only known websocket frames', () => { + expect(readStudentCallEvent({ id: '1', class_id: '2', kind: 'created' })?.kind).toBe('created'); + expect(readStudentCallEvent({ id: '1', class_id: '2', kind: 'read' })?.kind).toBe('read'); + for (const value of [ + null, + {}, + 'read', + { id: 1, class_id: '2', kind: 'read' }, + { id: '', class_id: '2', kind: 'created' }, + { id: '1', class_id: '2', kind: 'delete' }, + ]) + expect(readStudentCallEvent(value)).toBeNull(); + }); + it('updates the first page global badge when reading a later page', () => { + const original = { pages: [page([], 3), page([call], 3)], pageParams: ['', 'cursor'] }; + const updated = applyStudentCallRead(original, handled)!; + expect(updated.pages.map((item) => item.unread_count)).toEqual([2, 2]); + expect(updated.pages[1].items[0]).toEqual(handled); + expect(original.pages[1].items[0].read_at).toBe(''); + expect(applyStudentCallRead(updated, handled)).toEqual(updated); + }); + it('never underflows or guesses unknown call counts', () => { + expect( + applyStudentCallRead({ pages: [page([call], 0)], pageParams: [''] }, handled)?.pages[0].unread_count, + ).toBe(0); + expect( + applyStudentCallRead({ pages: [page([], 3)], pageParams: [''] }, handled)?.pages[0].unread_count, + ).toBe(3); + expect(applyStudentCallRead(undefined, handled)).toBeUndefined(); + }); +}); diff --git a/web/apps/teacher/src/studentCalls.ts b/web/apps/teacher/src/studentCalls.ts new file mode 100644 index 0000000..f34d434 --- /dev/null +++ b/web/apps/teacher/src/studentCalls.ts @@ -0,0 +1,53 @@ +import { useInfiniteQuery, type InfiniteData } from '@tanstack/react-query'; +import { ApiError, api } from '@classding/api'; +import type { StudentCall, StudentCallPage } from './models'; + +export const studentCallsKey = (userId: string) => ['student-calls', userId] as const; +export const studentCallsUnavailable = (error: unknown) => error instanceof ApiError && error.status === 404; + +export function useStudentCalls(userId: string) { + return useInfiniteQuery({ + queryKey: studentCallsKey(userId), + initialPageParam: '', + queryFn: ({ signal, pageParam }) => + api(`/student-calls${pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ''}`, { + signal, + }), + getNextPageParam: (page) => page.next_cursor ?? undefined, + enabled: !!userId, + retry: (count, error) => !studentCallsUnavailable(error) && count < 1, + refetchInterval: (query) => (studentCallsUnavailable(query.state.error) ? false : 30000), + refetchIntervalInBackground: true, + refetchOnMount: (query) => !studentCallsUnavailable(query.state.error), + refetchOnWindowFocus: (query) => !studentCallsUnavailable(query.state.error), + refetchOnReconnect: (query) => !studentCallsUnavailable(query.state.error), + }); +} + +// Keep the badge correct immediately after a confirmed shared read, then reconcile with the server. +export function applyStudentCallRead(data: InfiniteData | undefined, call: StudentCall) { + if (!data) return data; + const previous = data.pages.flatMap((page) => page.items).find((item) => item.id === call.id); + const decrement = previous && !previous.read_at && call.read_at ? 1 : 0; + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + items: page.items.map((item) => (item.id === call.id ? call : item)), + unread_count: Math.max(0, page.unread_count - decrement), + })), + }; +} + +export type StudentCallEvent = { id: string; class_id: string; kind: 'created' | 'read' }; +export function readStudentCallEvent(data: unknown): StudentCallEvent | null { + if (!data || typeof data !== 'object') return null; + const value = data as Partial; + return typeof value.id === 'string' && + value.id !== '' && + typeof value.class_id === 'string' && + value.class_id !== '' && + (value.kind === 'created' || value.kind === 'read') + ? (value as StudentCallEvent) + : null; +} diff --git a/web/packages/api/package.json b/web/packages/api/package.json index b77d1f0..8b5c758 100644 --- a/web/packages/api/package.json +++ b/web/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@classding/api", - "version": "0.3.4", + "version": "0.3.5", "private": true, "type": "module", "exports": { diff --git a/web/packages/api/src/types.ts b/web/packages/api/src/types.ts index 60141d6..1667597 100644 --- a/web/packages/api/src/types.ts +++ b/web/packages/api/src/types.ts @@ -515,7 +515,58 @@ export interface paths { get: operations["get__messages_36"]; put?: never; /** POST /messages */ - post: operations["post__messages_37"]; + post: operations["post__messages_40"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/device/student-calls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** POST /device/student-calls */ + post: operations["post__device_student_calls_37"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/student-calls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** GET /student-calls */ + get: operations["get__student_calls_38"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/student-calls/{id}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** POST /student-calls/{id}/read */ + post: operations["post__student_calls__id__read_39"]; delete?: never; options?: never; head?: never; @@ -530,11 +581,11 @@ export interface paths { cookie?: never; }; /** GET /messages/{id} */ - get: operations["get__messages__id__38"]; + get: operations["get__messages__id__41"]; put?: never; post?: never; /** DELETE /messages/{id} */ - delete: operations["delete__messages__id__39"]; + delete: operations["delete__messages__id__42"]; options?: never; head?: never; patch?: never; @@ -550,7 +601,7 @@ export interface paths { get?: never; put?: never; /** POST /messages/{id}/recall */ - post: operations["post__messages__id__recall_40"]; + post: operations["post__messages__id__recall_43"]; delete?: never; options?: never; head?: never; @@ -567,7 +618,7 @@ export interface paths { get?: never; put?: never; /** POST /messages/{id}/resend */ - post: operations["post__messages__id__resend_41"]; + post: operations["post__messages__id__resend_44"]; delete?: never; options?: never; head?: never; @@ -582,10 +633,10 @@ export interface paths { cookie?: never; }; /** GET /phrases */ - get: operations["get__phrases_42"]; + get: operations["get__phrases_45"]; put?: never; /** POST /phrases */ - post: operations["post__phrases_43"]; + post: operations["post__phrases_46"]; delete?: never; options?: never; head?: never; @@ -603,11 +654,11 @@ export interface paths { put?: never; post?: never; /** DELETE /phrases/{id} */ - delete: operations["delete__phrases__id__45"]; + delete: operations["delete__phrases__id__48"]; options?: never; head?: never; /** PATCH /phrases/{id} */ - patch: operations["patch__phrases__id__44"]; + patch: operations["patch__phrases__id__47"]; trace?: never; }; "/classes/{id}/tools/countdown": { @@ -620,7 +671,7 @@ export interface paths { get?: never; put?: never; /** POST /classes/{id}/tools/countdown */ - post: operations["post__classes__id__tools_countdown_46"]; + post: operations["post__classes__id__tools_countdown_49"]; delete?: never; options?: never; head?: never; @@ -637,7 +688,7 @@ export interface paths { get?: never; put?: never; /** POST /classes/{id}/tools/rollcall */ - post: operations["post__classes__id__tools_rollcall_47"]; + post: operations["post__classes__id__tools_rollcall_50"]; delete?: never; options?: never; head?: never; @@ -654,7 +705,7 @@ export interface paths { get?: never; put?: never; /** POST /classes/{id}/tools/stop */ - post: operations["post__classes__id__tools_stop_48"]; + post: operations["post__classes__id__tools_stop_51"]; delete?: never; options?: never; head?: never; @@ -671,7 +722,7 @@ export interface paths { get?: never; put?: never; /** POST /audio */ - post: operations["post__audio_49"]; + post: operations["post__audio_52"]; delete?: never; options?: never; head?: never; @@ -686,7 +737,7 @@ export interface paths { cookie?: never; }; /** GET /admin/overview */ - get: operations["get__admin_overview_50"]; + get: operations["get__admin_overview_53"]; put?: never; post?: never; delete?: never; @@ -703,10 +754,10 @@ export interface paths { cookie?: never; }; /** GET /admin/activation-codes */ - get: operations["get__admin_activation_codes_51"]; + get: operations["get__admin_activation_codes_54"]; put?: never; /** POST /admin/activation-codes */ - post: operations["post__admin_activation_codes_52"]; + post: operations["post__admin_activation_codes_55"]; delete?: never; options?: never; head?: never; @@ -727,7 +778,7 @@ export interface paths { options?: never; head?: never; /** PATCH /admin/activation-codes/{code} */ - patch: operations["patch__admin_activation_codes__code__53"]; + patch: operations["patch__admin_activation_codes__code__56"]; trace?: never; }; "/admin/activation-codes/delete": { @@ -740,7 +791,7 @@ export interface paths { get?: never; put?: never; /** POST /admin/activation-codes/delete */ - post: operations["post__admin_activation_codes_delete_54"]; + post: operations["post__admin_activation_codes_delete_57"]; delete?: never; options?: never; head?: never; @@ -755,10 +806,10 @@ export interface paths { cookie?: never; }; /** GET /admin/schools */ - get: operations["get__admin_schools_55"]; + get: operations["get__admin_schools_58"]; put?: never; /** POST /admin/schools */ - post: operations["post__admin_schools_56"]; + post: operations["post__admin_schools_59"]; delete?: never; options?: never; head?: never; @@ -776,11 +827,11 @@ export interface paths { put?: never; post?: never; /** DELETE /admin/schools/{id} */ - delete: operations["delete__admin_schools__id__58"]; + delete: operations["delete__admin_schools__id__61"]; options?: never; head?: never; /** PATCH /admin/schools/{id} */ - patch: operations["patch__admin_schools__id__57"]; + patch: operations["patch__admin_schools__id__60"]; trace?: never; }; "/admin/users": { @@ -791,10 +842,10 @@ export interface paths { cookie?: never; }; /** GET /admin/users */ - get: operations["get__admin_users_59"]; + get: operations["get__admin_users_62"]; put?: never; /** POST /admin/users */ - post: operations["post__admin_users_60"]; + post: operations["post__admin_users_63"]; delete?: never; options?: never; head?: never; @@ -812,11 +863,11 @@ export interface paths { put?: never; post?: never; /** DELETE /admin/users/{id} */ - delete: operations["delete__admin_users__id__62"]; + delete: operations["delete__admin_users__id__65"]; options?: never; head?: never; /** PATCH /admin/users/{id} */ - patch: operations["patch__admin_users__id__61"]; + patch: operations["patch__admin_users__id__64"]; trace?: never; }; "/admin/users/{id}/unlock-login": { @@ -829,7 +880,7 @@ export interface paths { get?: never; put?: never; /** POST /admin/users/{id}/unlock-login */ - post: operations["post__admin_users__id__unlock_login_63"]; + post: operations["post__admin_users__id__unlock_login_66"]; delete?: never; options?: never; head?: never; @@ -844,10 +895,10 @@ export interface paths { cookie?: never; }; /** GET /admin/classes */ - get: operations["get__admin_classes_64"]; + get: operations["get__admin_classes_67"]; put?: never; /** POST /admin/classes */ - post: operations["post__admin_classes_65"]; + post: operations["post__admin_classes_68"]; delete?: never; options?: never; head?: never; @@ -865,11 +916,11 @@ export interface paths { put?: never; post?: never; /** DELETE /admin/classes/{id} */ - delete: operations["delete__admin_classes__id__67"]; + delete: operations["delete__admin_classes__id__70"]; options?: never; head?: never; /** PATCH /admin/classes/{id} */ - patch: operations["patch__admin_classes__id__66"]; + patch: operations["patch__admin_classes__id__69"]; trace?: never; }; "/admin/devices": { @@ -880,7 +931,7 @@ export interface paths { cookie?: never; }; /** GET /admin/devices */ - get: operations["get__admin_devices_68"]; + get: operations["get__admin_devices_71"]; put?: never; post?: never; delete?: never; @@ -897,7 +948,7 @@ export interface paths { cookie?: never; }; /** GET /admin/messages */ - get: operations["get__admin_messages_69"]; + get: operations["get__admin_messages_72"]; put?: never; post?: never; delete?: never; @@ -917,7 +968,7 @@ export interface paths { put?: never; post?: never; /** DELETE /admin/messages/{id} */ - delete: operations["delete__admin_messages__id__70"]; + delete: operations["delete__admin_messages__id__73"]; options?: never; head?: never; patch?: never; @@ -931,7 +982,7 @@ export interface paths { cookie?: never; }; /** GET /admin/audit-logs */ - get: operations["get__admin_audit_logs_71"]; + get: operations["get__admin_audit_logs_74"]; put?: never; post?: never; delete?: never; @@ -1019,6 +1070,23 @@ export interface components { text: string; sort: number; }; + StudentCall: { + id: string; + class_id: string; + class_name: string; + device_id: string; + device_name: string; + /** Format: date-time */ + created_at: string; + read_at: string; + read_by: string; + read_by_name: string; + }; + StudentCallList: { + items: components["schemas"]["StudentCall"][]; + unread_count: number; + next_cursor: string | null; + }; Homework: { id: string; class_id: string; @@ -2458,7 +2526,7 @@ export interface operations { }; }; }; - post__messages_37: { + post__messages_40: { parameters: { query?: never; header?: never; @@ -2491,7 +2559,106 @@ export interface operations { }; }; }; - get__messages__id__38: { + post__device_student_calls_37: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + request_id: string; + }; + }; + }; + responses: { + /** @description 成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StudentCall"]; + }; + }; + /** @description 业务错误 */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get__student_calls_38: { + parameters: { + query?: { + class_id?: string; + cursor?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StudentCallList"]; + }; + }; + /** @description 业务错误 */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + post__student_calls__id__read_39: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StudentCall"]; + }; + }; + /** @description 业务错误 */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get__messages__id__41: { parameters: { query?: never; header?: never; @@ -2522,7 +2689,7 @@ export interface operations { }; }; }; - delete__messages__id__39: { + delete__messages__id__42: { parameters: { query?: never; header?: never; @@ -2553,7 +2720,7 @@ export interface operations { }; }; }; - post__messages__id__recall_40: { + post__messages__id__recall_43: { parameters: { query?: never; header?: never; @@ -2584,7 +2751,7 @@ export interface operations { }; }; }; - post__messages__id__resend_41: { + post__messages__id__resend_44: { parameters: { query?: never; header?: never; @@ -2615,7 +2782,7 @@ export interface operations { }; }; }; - get__phrases_42: { + get__phrases_45: { parameters: { query?: never; header?: never; @@ -2644,7 +2811,7 @@ export interface operations { }; }; }; - post__phrases_43: { + post__phrases_46: { parameters: { query?: never; header?: never; @@ -2679,7 +2846,7 @@ export interface operations { }; }; }; - delete__phrases__id__45: { + delete__phrases__id__48: { parameters: { query?: never; header?: never; @@ -2710,7 +2877,7 @@ export interface operations { }; }; }; - patch__phrases__id__44: { + patch__phrases__id__47: { parameters: { query?: never; header?: never; @@ -2747,7 +2914,7 @@ export interface operations { }; }; }; - post__classes__id__tools_countdown_46: { + post__classes__id__tools_countdown_49: { parameters: { query?: never; header?: never; @@ -2784,7 +2951,7 @@ export interface operations { }; }; }; - post__classes__id__tools_rollcall_47: { + post__classes__id__tools_rollcall_50: { parameters: { query?: never; header?: never; @@ -2821,7 +2988,7 @@ export interface operations { }; }; }; - post__classes__id__tools_stop_48: { + post__classes__id__tools_stop_51: { parameters: { query?: never; header?: never; @@ -2852,7 +3019,7 @@ export interface operations { }; }; }; - post__audio_49: { + post__audio_52: { parameters: { query?: never; header?: never; @@ -2888,7 +3055,7 @@ export interface operations { }; }; }; - get__admin_overview_50: { + get__admin_overview_53: { parameters: { query?: never; header?: never; @@ -2917,7 +3084,7 @@ export interface operations { }; }; }; - get__admin_activation_codes_51: { + get__admin_activation_codes_54: { parameters: { query?: { class_id?: string; @@ -2951,7 +3118,7 @@ export interface operations { }; }; }; - post__admin_activation_codes_52: { + post__admin_activation_codes_55: { parameters: { query?: never; header?: never; @@ -2991,7 +3158,7 @@ export interface operations { }; }; }; - patch__admin_activation_codes__code__53: { + patch__admin_activation_codes__code__56: { parameters: { query?: never; header?: never; @@ -3031,7 +3198,7 @@ export interface operations { }; }; }; - post__admin_activation_codes_delete_54: { + post__admin_activation_codes_delete_57: { parameters: { query?: never; header?: never; @@ -3068,7 +3235,7 @@ export interface operations { }; }; }; - get__admin_schools_55: { + get__admin_schools_58: { parameters: { query?: never; header?: never; @@ -3097,7 +3264,7 @@ export interface operations { }; }; }; - post__admin_schools_56: { + post__admin_schools_59: { parameters: { query?: never; header?: never; @@ -3132,7 +3299,7 @@ export interface operations { }; }; }; - delete__admin_schools__id__58: { + delete__admin_schools__id__61: { parameters: { query?: never; header?: never; @@ -3163,7 +3330,7 @@ export interface operations { }; }; }; - patch__admin_schools__id__57: { + patch__admin_schools__id__60: { parameters: { query?: never; header?: never; @@ -3200,7 +3367,7 @@ export interface operations { }; }; }; - get__admin_users_59: { + get__admin_users_62: { parameters: { query?: never; header?: never; @@ -3229,7 +3396,7 @@ export interface operations { }; }; }; - post__admin_users_60: { + post__admin_users_63: { parameters: { query?: never; header?: never; @@ -3267,7 +3434,7 @@ export interface operations { }; }; }; - delete__admin_users__id__62: { + delete__admin_users__id__65: { parameters: { query?: never; header?: never; @@ -3298,7 +3465,7 @@ export interface operations { }; }; }; - patch__admin_users__id__61: { + patch__admin_users__id__64: { parameters: { query?: never; header?: never; @@ -3339,7 +3506,7 @@ export interface operations { }; }; }; - post__admin_users__id__unlock_login_63: { + post__admin_users__id__unlock_login_66: { parameters: { query?: never; header?: never; @@ -3370,7 +3537,7 @@ export interface operations { }; }; }; - get__admin_classes_64: { + get__admin_classes_67: { parameters: { query?: never; header?: never; @@ -3399,7 +3566,7 @@ export interface operations { }; }; }; - post__admin_classes_65: { + post__admin_classes_68: { parameters: { query?: never; header?: never; @@ -3438,7 +3605,7 @@ export interface operations { }; }; }; - delete__admin_classes__id__67: { + delete__admin_classes__id__70: { parameters: { query?: never; header?: never; @@ -3469,7 +3636,7 @@ export interface operations { }; }; }; - patch__admin_classes__id__66: { + patch__admin_classes__id__69: { parameters: { query?: never; header?: never; @@ -3510,7 +3677,7 @@ export interface operations { }; }; }; - get__admin_devices_68: { + get__admin_devices_71: { parameters: { query?: never; header?: never; @@ -3539,7 +3706,7 @@ export interface operations { }; }; }; - get__admin_messages_69: { + get__admin_messages_72: { parameters: { query?: { class_id?: string; @@ -3573,7 +3740,7 @@ export interface operations { }; }; }; - delete__admin_messages__id__70: { + delete__admin_messages__id__73: { parameters: { query?: never; header?: never; @@ -3604,7 +3771,7 @@ export interface operations { }; }; }; - get__admin_audit_logs_71: { + get__admin_audit_logs_74: { parameters: { query?: never; header?: never; diff --git a/web/packages/api/src/ws-types.ts b/web/packages/api/src/ws-types.ts index 84de85b..0d2a68c 100644 --- a/web/packages/api/src/ws-types.ts +++ b/web/packages/api/src/ws-types.ts @@ -199,6 +199,18 @@ export interface components { device_id: string; online: boolean; }; + } | { + /** @constant */ + t: "student_call"; + id?: string; + seq?: number; + ts?: number; + d: { + id: string; + class_id: string; + /** @enum {string} */ + kind: "created" | "read"; + }; }; }; responses: never; diff --git a/web/packages/ui/package.json b/web/packages/ui/package.json index e1b0128..14a6be2 100644 --- a/web/packages/ui/package.json +++ b/web/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@classding/ui", - "version": "0.3.4", + "version": "0.3.5", "private": true, "type": "module", "exports": {