diff --git a/README.md b/README.md index 7da433c7..f19e6177 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ yay -S ubaa ## 核心能力 - 多端统一:Android、iOS、Desktop、Web 共用核心能力与大部分业务代码。 -- 校园服务聚合:统一认证、课表、考试、空闲教室、博雅、签到、评教等能力集中接入。 +- 校园服务聚合:统一认证、课表、考试、成绩、空闲教室、博雅、SPOC、希冀、图书馆座位、签到、研讨室、阳光打卡、评教等能力集中接入。 - 全栈同仓:`shared` 统一前后端契约,`server` 负责网关与会话管理,`composeApp` 负责跨平台 UI。 - 现代体验:基于 Material Design 3,支持系统主题适配与持续更新。 @@ -46,5 +46,8 @@ UBAA/ ├── shared/ # 前后端共享契约与通用逻辑 ├── server/ # Ktor 后端网关 ├── androidApp/ # Android 壳工程 -└── iosApp/ # iOS 壳工程 +├── iosApp/ # iOS 壳工程 +├── buildSrc/ # 自定义 Gradle 任务 +├── docs/ # VitePress 文档 +└── .github/ # CI、发布和文档部署 workflow ``` diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt index cf1f2244..02bdc48b 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt @@ -801,7 +801,6 @@ fun MainAppScreen( ) AppScreen.ADVANCED -> AdvancedFeaturesScreen( - onSigninClick = { navigateTo(AppScreen.SIGNIN) }, onCgyyClick = { navigateTo(AppScreen.CGYY_HOME) }, onEvaluationClick = { navigateTo(AppScreen.EVALUATION) }, onYgdkClick = { navigateTo(AppScreen.YGDK_HOME) }, diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/AdvancedFeaturesScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/AdvancedFeaturesScreen.kt index 5a1019c5..e0b40ab3 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/AdvancedFeaturesScreen.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/AdvancedFeaturesScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AssignmentTurnedIn import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.HowToReg import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.WbSunny import androidx.compose.material3.Card @@ -32,54 +31,49 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -private data class AdvancedFeatureItem( +internal data class AdvancedFeatureItem( val id: String, val title: String, val description: String, val icon: ImageVector, ) +internal fun advancedFeatureItems(): List = + listOf( + AdvancedFeatureItem( + id = "cgyy", + title = "研讨室预约", + description = "查询、提交和管理研讨室预约", + icon = Icons.Default.DateRange, + ), + AdvancedFeatureItem( + id = "ygdk", + title = "阳光打卡", + description = "查看记录并提交体育活动打卡", + icon = Icons.Default.WbSunny, + ), + AdvancedFeatureItem( + id = "evaluation", + title = "自动评教", + description = "一键完成学期末评教任务", + icon = Icons.Default.AssignmentTurnedIn, + ), + AdvancedFeatureItem( + id = "more", + title = "更多功能", + description = "更多高级功能正在开发中...", + icon = Icons.Default.MoreHoriz, + ), + ) + @Composable fun AdvancedFeaturesScreen( - onSigninClick: () -> Unit, onCgyyClick: () -> Unit, onEvaluationClick: () -> Unit, onYgdkClick: () -> Unit, modifier: Modifier = Modifier, ) { - val features = - listOf( - AdvancedFeatureItem( - id = "signin", - title = "课程签到", - description = "快速完成课堂签到", - icon = Icons.Default.HowToReg, - ), - AdvancedFeatureItem( - id = "cgyy", - title = "研讨室预约", - description = "查询、提交和管理研讨室预约", - icon = Icons.Default.DateRange, - ), - AdvancedFeatureItem( - id = "ygdk", - title = "阳光打卡", - description = "查看记录并提交体育活动打卡", - icon = Icons.Default.WbSunny, - ), - AdvancedFeatureItem( - id = "evaluation", - title = "自动评教", - description = "一键完成学期末评教任务", - icon = Icons.Default.AssignmentTurnedIn, - ), - AdvancedFeatureItem( - id = "more", - title = "更多功能", - description = "更多高级功能正在开发中...", - icon = Icons.Default.MoreHoriz, - ), - ) + val features = advancedFeatureItems() Column(modifier = modifier.fillMaxSize().padding(16.dp)) { LazyVerticalGrid( @@ -92,7 +86,6 @@ fun AdvancedFeaturesScreen( feature = feature, onClick = { when (feature.id) { - "signin" -> onSigninClick() "cgyy" -> onCgyyClick() "ygdk" -> onYgdkClick() "evaluation" -> onEvaluationClick() diff --git a/composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/AdvancedFeaturesScreenTest.kt b/composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/AdvancedFeaturesScreenTest.kt new file mode 100644 index 00000000..65594941 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/AdvancedFeaturesScreenTest.kt @@ -0,0 +1,12 @@ +package cn.edu.ubaa.ui + +import cn.edu.ubaa.ui.screens.menu.advancedFeatureItems +import kotlin.test.Test +import kotlin.test.assertFalse + +class AdvancedFeaturesScreenTest { + @Test + fun `advanced features do not include signin entry`() { + assertFalse(advancedFeatureItems().any { it.id == "signin" }) + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 17146a60..e8e56944 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -29,6 +29,7 @@ export default defineConfig({ { text: '空教室查询', link: '/features/classroom' }, { text: 'SPOC 作业', link: '/features/spoc' }, { text: '希冀作业', link: '/features/judge' }, + { text: '图书馆座位', link: '/features/libbook' }, { text: '课程签到', link: '/features/signin' }, { text: '研讨室预约', link: '/features/cgyy' }, { text: '阳光打卡', link: '/features/ygdk' }, diff --git a/docs/_audit/2026-06-08-full-repo-docs-review.md b/docs/_audit/2026-06-08-full-repo-docs-review.md new file mode 100644 index 00000000..1600f267 --- /dev/null +++ b/docs/_audit/2026-06-08-full-repo-docs-review.md @@ -0,0 +1,125 @@ +# UBAA 全仓库审查与文档同步报告 + +## 基线 + +- 分支:`dev` +- 工作区状态:`git -c safe.directory=D:/Code/Kotlin/UBAA status --short` 无输出,审查开始时工作区干净。 +- 审查日期:2026-06-08 +- 最近提交:`2fba33e (HEAD -> dev, origin/dev) 更新公告` +- 已确认存在的顶层路径:`shared/`、`server/`、`composeApp/`、`androidApp/`、`iosApp/`、`buildSrc/`、`.github/workflows/`、`docs/`。 +- 已确认不存在的顶层路径:`harmonyApp/`。 +- 当前 workflow:`docs.yml`、`format.yml`、`release.yml`、`test.yml`。 +- 审查约束:未运行真实上传、预约、签到或其他有副作用的校园服务操作。 + +## 代码事实 + +### 构建、版本与模块 + +- `settings.gradle.kts` 只包含 `:androidApp`、`:composeApp`、`:server`、`:shared` 四个 Gradle 子模块;仓库当前没有 `harmonyApp/` 顶层目录。 +- `gradle/libs.versions.toml` 当前版本:Kotlin `2.3.20`、Compose Multiplatform `1.10.3`、Ktor `3.4.1`、AGP `9.1.0`;Android compile/target SDK 为 `36`,min SDK 为 `24`。 +- `gradle.properties` 当前项目版本为 `project.version=1.7.5`、`project.version.code=28`。 +- `shared/build.gradle.kts`、`composeApp/build.gradle.kts`、`server/build.gradle.kts` 均使用 JDK 21 toolchain;Android 壳工程使用 Java 21 编译目标。 +- `shared` 和 `composeApp` 的平台目标包含 Android、iOS Arm64/iOS Simulator Arm64、JVM、JS Browser、Wasm JS Browser;JS/Wasm 运行时不支持本地连接模式。 +- 根 `build.gradle.kts` 注册 `verifyBhpanReadOnly` 和 `uploadLatestReleaseToBhpan`。前者是只读认证探测任务,后者会删除并重新上传网盘中的 `UBAA-*` 发布资产,本次审查未运行。 +- `./gradlew.bat tasks --all --console=plain` 已成功列出任务,确认存在 `:shared:jvmTest`、`:server:test`、`:composeApp:jvmTest`、`:composeApp:wasmJsBrowserDistribution`、`:composeApp:jsBrowserDistribution`、`spotlessCheck`、`verifyBhpanReadOnly`、`uploadLatestReleaseToBhpan`。 + +### 共享层与连接模式 + +- `shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/` 保存跨客户端和服务端复用的 DTO;`api/feature/` 保存业务 backend 契约和 relay backend;`api/local/` 保存本地直连/WebVPN backend;`api/storage/` 保存跨平台本地设置。 +- `ConnectionRuntime.availableModes()` 在 Android、iOS、JVM 上返回 `DIRECT`、`WEBVPN`、`SERVER_RELAY`;在 JS 和 Wasm JS 上只返回 `SERVER_RELAY`,并自动保存该模式。 +- 切换连接模式会调用 `resetSession()`,清空 Auth Token、ClientId、本地认证会话、Cookie、自动登录开关、API client、本地上游 client、希冀缓存和学期缓存。 +- `DefaultApiFactory` 对所有业务 API 做模式分发:`DIRECT`/`WEBVPN` 使用 `LocalBackendSet`,`SERVER_RELAY` 使用 relay backend;当前已包含 `libBookApi()`。 +- `LocalWebVpnSupport.localUpstreamUrl()` 只在当前模式为 `WEBVPN` 时包装 WebVPN URL;`localCgyyUpstreamUrl()` 当前始终返回原 URL,代码注释说明 CGYY 上游公开可访问,因此本地 CGYY 即使处于 WebVPN 模式也走直连 URL。 + +### 服务端与路由 + +- `server/src/main/kotlin/cn/edu/ubaa/Application.kt` 安装 ContentNegotiation、CORS、JWT、CallId、CallLogging、Micrometer 指标和全局错误处理。 +- 公开路由包含 `/`、`/metrics`、`/health/live`、`/health/ready`、`/api/v1/app/version`、`/api/v1/app/announcement`、`/api/v1/auth/*`。 +- JWT 保护下注册 user、schedule、bykc、exam、grade、signin、classroom、cgyy、evaluation、spoc、judge、libbook、ygdk 业务路由。 +- `Application.module()` 定时清理 session、prelogin、signin、bykc、cgyy、spoc、judge、libbook、ygdk 上游客户端或缓存,并绑定 `ubaa.libbook.cache` 等 Prometheus gauge。 +- `/api/v1/app/version` 由 `AppVersionService` 实现,服务端版本来源优先级为系统属性、`UBAA_SERVER_VERSION`、manifest、`gradle.properties`,下载地址来自 `UPDATE_DOWNLOAD_URL` 或 GitHub Releases。 +- `AppVersionCheckResponse` 当前包含 `latestVersion`、`status`、`updateAvailable`、`downloadUrl`、`releaseNotes`,并保留旧兼容字段 `serverVersion`、`aligned`;客户端 `UpdateService` 只在 `UPDATE_AVAILABLE` 时返回更新响应。 + +### UI 与功能 + +- `composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt` 是导航中心,已包含 `LIBBOOK_HOME`、`LIBBOOK_RESERVE`、`LIBBOOK_BOOKINGS` 三个图书馆座位页面。 +- `RegularFeaturesScreen.kt` 普通功能列表包含课表、考试、成绩、博雅、空教室、SPOC、希冀和图书馆座位。 +- `AdvancedFeaturesScreen.kt` 高级功能列表包含研讨室、阳光打卡、自动评教等入口;课程签到入口已从高级功能中隐藏。 +- `HomeTodo.kt` 当前聚合博雅、SPOC、希冀、研讨室、签到、阳光打卡待办;没有图书馆座位待办来源。 +- 图书馆座位功能已在 UI、shared、本地 backend 和 server relay 中实现:包含楼馆、分区、区域详情、座位、预约、预约列表、取消预约。 + +### 高风险专项 + +- CGYY/研讨室:`LocalCgyyApiBackend` 提供场地、目的类型、日期时段、预约提交、订单、取消和门锁码;预约提交与取消是写操作。本地实现使用 `localCgyyUpstreamUrl()`,当前不走 WebVPN 包装;服务端 `CgyyZhjsClient` 也直接访问 CGYY 上游并处理 `sso/manageLogin`。 +- Judge/希冀:`LocalJudgeApiBackend` 使用 `localUpstreamUrl()` 支持直连/WebVPN,列表缓存 TTL 为 5 分钟、详情缓存 TTL 为 2 分钟,详情查询并发数为 4,并维护历史/已截止课程跳过记录。 +- LibBook/图书馆座位:服务端路由包含 `GET /api/v1/libbook/libraries`、`GET /api/v1/libbook/areas`、`GET /api/v1/libbook/areas/{areaId}`、`GET /api/v1/libbook/areas/{areaId}/seats`、`POST /api/v1/libbook/bookings`、`GET /api/v1/libbook/reservations`、`POST /api/v1/libbook/bookings/{bookingId}/cancel`;预约与取消是写操作。 +- 发布与部署:当前 `.github/workflows/` 只有 `docs.yml`、`format.yml`、`release.yml`、`test.yml`。`.github/workflows/upload.yml` 不存在,Web Wasm 构建、Release 资产上传、Cloudflare Pages 部署和 `app.buaa.team` 缓存刷新都在 `release.yml` 的 `build-web-wasm` job 中完成。 +- Docs 发布:`docs.yml` 在 `dev` 分支 docs/README/package/workflow 变更时触发,使用 Node 22、`npm ci`、`npm run docs:build`,随后通过 SSH rsync 发布 `docs/.vitepress/dist/`,并刷新 `www.buaa.team` Cloudflare 缓存。 + +## 文档漂移 + +以下漂移已在本次文档同步中处理: + +- `docs/tech/configuration.md`、`docs/tech/release-deployment.md`、`docs/_audit/source-inventory.md` 曾把不存在的 `.github/workflows/upload.yml` 当作当前 workflow;现已改为记录 `docs.yml`、`format.yml`、`release.yml`、`test.yml`,并说明 Web Wasm 发布由 `release.yml` 处理。 +- `docs/tech/configuration.md` 曾称 BuildKonfig 写入 `APP_VERSION`、`VERSION_CODE`;现已改为当前代码中的 `VERSION` 与 `API_ENDPOINT`。 +- `docs/index.md` 的“三种连接模式”表述曾容易误导为所有平台都能自由选择;现已说明 Web/Wasm 固定服务器中转,Android、iOS、Desktop 可选择本地连接模式。 +- `docs/features/index.md`、`docs/.vitepress/config.mts` 缺少已实现的图书馆座位功能页和侧边栏入口;现已新增 `docs/features/libbook.md` 并加入侧边栏。 +- `docs/features/cgyy.md` 曾只说本地 backend 实现直连/WebVPN;现已补充当前 CGYY 本地 URL 总是直连、不走 WebVPN 包装的事实。 +- `docs/tech/server-routes.md` 缺少 `/api/v1/libbook/*`;现已补齐具体路由。 +- `docs/tech/shared-api.md` 缺少 `LibBookApi.kt`、`LibBook.kt` 分类与来源;现已补齐。 +- `docs/tech/state-storage.md` 的服务端业务缓存列表缺少 libbook;现已补齐。 +- `docs/announcements/history.md` 未列出已存在的 `2026-06-03-001.md`;现已加入历史页。 +- `docs/announcements/index.md` 的公告示例仍使用旧的 `docs.buaa.team`;现按 docs workflow 的 `www.buaa.team` 缓存刷新目标改为 `www.buaa.team`。 + +未直接改写的漂移/风险: + +- `docs/changelog/index.md` 最新条目停在 `v1.7.2`,而 `gradle.properties` 当前版本为 `1.7.5`。本地没有可追溯的缺失 release notes,且任务要求不伪造版本记录,因此未补写版本条目。 + +## 已修改的文档 + +- `README.md`:补充图书馆座位等当前功能,并同步仓库目录树中的 `buildSrc/`、`docs/`、`.github/`。 +- `docs/index.md`:补充图书馆座位,修正 Web/Wasm 连接模式限制。 +- `docs/.vitepress/config.mts`:新增图书馆座位侧边栏入口。 +- `docs/features/index.md`:新增图书馆座位总览,修正首页待办来源。 +- `docs/features/libbook.md`:新建图书馆座位功能页。 +- `docs/features/auth-and-connection.md`:明确 Android/iOS/Desktop 与 Web/Wasm 的连接模式差异。 +- `docs/features/cgyy.md`:补充 CGYY 本地实现的直连例外和写操作风险。 +- `docs/tech/architecture.md`:补充 `buildSrc` 职责。 +- `docs/tech/modules.md`:补充 source set、`harmonyApp/` 不存在和 bhpan Gradle 任务边界。 +- `docs/tech/connection-modes.md`:补充平台实际支持与 CGYY 例外。 +- `docs/tech/shared-api.md`:补充 LibBook API/DTO 和版本检查兼容字段。 +- `docs/tech/server-routes.md`:补充 `/api/v1/libbook/*` 路由。 +- `docs/tech/state-storage.md`:补充 libbook 服务端缓存。 +- `docs/tech/configuration.md`:修正 BuildKonfig 字段、服务端版本/下载环境变量和当前 workflow 列表。 +- `docs/tech/testing.md`:补充本地 docs build、Gradle 追加验证和 bhpan 上传任务风险。 +- `docs/tech/release-deployment.md`:移除旧 upload workflow 说法,补充 release.yml Web Wasm 部署和 Cloudflare 缓存刷新。 +- `docs/announcements/history.md`:补充 2026-06-03 公告入口。 +- `docs/announcements/index.md`:修正公告示例站点域名。 +- `docs/_audit/source-inventory.md`:按当前仓库重新统计并清理 `upload.yml` 旧记录。 +- `docs/_audit/2026-06-08-full-repo-docs-review.md`:记录本次审查事实、漂移、文档变更、验证和风险。 + +## 验证记录 + +- `git -c safe.directory=D:/Code/Kotlin/UBAA status --short`:退出码 0,无输出。 +- `git -c safe.directory=D:/Code/Kotlin/UBAA branch --show-current`:退出码 0,输出 `dev`。 +- `git -c safe.directory=D:/Code/Kotlin/UBAA log --oneline --decorate --max-count=12`:退出码 0,确认最近 12 条提交。 +- `rg --files -g '!**/build/**' -g '!**/.gradle/**' -g '!docs/.vitepress/dist/**' -g '!docs/.vitepress/cache/**' -g '!node_modules/**'`:退出码 0,完成基线文件清单扫描。 +- 最终重跑 `npm run docs:build`:退出码 0,VitePress `v2.0.0-alpha.17` 构建成功;输出包含 `@vueuse/core` 中 `/* #__PURE__ */` 注释位置的 Rollup 警告,但未导致构建失败。 +- 最终重跑 `git -c safe.directory=D:/Code/Kotlin/UBAA diff --check`:退出码 0;输出仅包含工作区文件未来会被 Git 从 LF 转为 CRLF 的警告,未报告 trailing whitespace 或 conflict marker。 +- `./gradlew.bat :shared:jvmTest`:退出码 1,`156 tests completed, 3 failed, 6 skipped`。失败集中在 `LocalCgyyApiBackendTest`: + - `cgyy api fetches direct upstream data` + - `cgyy api submits reservation and handles order actions in direct mode` + - `cgyy api uses webvpn wrapped urls when current mode is webvpn` +- 对 `:shared:jvmTest` 失败的只读调查结论:当前 `LocalCgyyApiBackend.ensureBusinessLogin()` 使用 `LocalUpstreamClientProvider.newClient(...)` 建立 DIRECT cookie 客户端,现有测试 helper 只覆盖 `clientFactory`;同时 `localCgyyUpstreamUrl()` 当前恒等返回原 URL,而测试名和断言仍期望 WebVPN 包装到 `d.buaa.edu.cn`。本次任务只改文档,未擅自修改业务代码或测试代码。 +- `./gradlew.bat :server:test`:退出码 0,`BUILD SUCCESSFUL`。 +- `./gradlew.bat :composeApp:jvmTest`:退出码 0,`BUILD SUCCESSFUL`。 +- `./gradlew.bat spotlessCheck`:退出码 0,`BUILD SUCCESSFUL`。 +- `git -c safe.directory=D:/Code/Kotlin/UBAA status --short`:退出码 0,仅显示 README、docs 下文档改动和新增审查/LibBook 文档。 +- `git -c safe.directory=D:/Code/Kotlin/UBAA diff -- README.md docs package.json .github build.gradle.kts settings.gradle.kts gradle.properties`:退出码 0,复核改动集中在 README 和 docs;未发现业务代码、生成产物或敏感配置改动。 + +## 剩余风险 + +- `:shared:jvmTest` 当前失败,失败点为现有 `LocalCgyyApiBackendTest` 与当前 CGYY 本地实现的 URL/客户端工厂路径不一致;本次按文档任务范围未修改业务代码或测试代码。 +- 未执行 `verifyBhpanReadOnly`,因为本次没有明确授权使用真实账号做 live probe。 +- 未执行 `uploadLatestReleaseToBhpan`、预约、签到、阳光打卡、评教或其他写操作。 +- `docs/changelog/index.md` 与 `gradle.properties` 版本存在潜在不同步,但缺少当前 release notes 的本地事实来源,未伪造更新日志。 diff --git a/docs/_audit/source-inventory.md b/docs/_audit/source-inventory.md index 1e122a0e..93495cb1 100644 --- a/docs/_audit/source-inventory.md +++ b/docs/_audit/source-inventory.md @@ -1,509 +1,135 @@ -# Source Inventory +# 源文件清单 -Generated during the docs implementation pass on 2026-05-07. The scan excluded `.git`, `build`, `node_modules`, `tmp`, `local.properties`, and VitePress output/cache. +生成时间:2026-06-08,全仓库文档审查期间。 -## Summary +本清单基于当前仓库状态生成,包含 Git 已跟踪文件和本次审查新增的未跟踪文档产物。扫描排除了 `build/`、`.gradle/`、`node_modules/`、`tmp/`、`local.properties`、`docs/.vitepress/dist/` 和 `docs/.vitepress/cache/`。 -- Total scanned files: 467 -- shared: 141 -- composeApp: 122 -- server: 120 -- docs: 28 -- androidApp: 23 -- iosApp: 13 -- .github: 5 -- gradle: 3 -- README.md: 1 -- package.json: 1 -- package-lock.json: 1 -- LICENSE: 1 -- gradle.properties: 1 -- gradlew: 1 -- settings.gradle.kts: 1 -- build.gradle.kts: 1 -- .gitignore: 1 -- .gitattributes: 1 -- gradlew.bat: 1 -- .env.sample: 1 +## 扫描命令 -## Status Legend +```powershell +$tracked = git -c safe.directory=D:/Code/Kotlin/UBAA ls-files +$untracked = git -c safe.directory=D:/Code/Kotlin/UBAA ls-files --others --exclude-standard +$files = @($tracked + $untracked) | Where-Object { + $_ -notmatch '(^|/)(build|\.gradle|node_modules|tmp)(/|$)' -and + $_ -notmatch '^docs/\.vitepress/(dist|cache)/' -and + $_ -ne 'local.properties' +} +``` -- 已覆盖: the file is covered by one or more public docs pages listed in the target column. -- 无需文档化: generated binary, wrapper artifact, icon, or repository metadata that does not need a public docs page. -- 生成或私密排除: excluded from this scan by design; see the excluded paths sentence above. +## 汇总 -## Files +- 扫描文件总数:545 +- `shared`:161 +- `composeApp`:158 +- `server`:127 +- `docs`:38 +- `androidApp`:23 +- `iosApp`:14 +- `.github`:4 +- `buildSrc`:3 +- `gradle`:3 +- 顶层单文件:`README.md`、`package.json`、`package-lock.json`、`LICENSE`、`kotlin-js-store`、`gradle.properties`、`gradlew`、`settings.gradle.kts`、`build.gradle.kts`、`.vscode`、`.gitignore`、`.gitattributes`、`gradlew.bat`、`.env.sample` -| Path | Status | Target doc | +## 顶层模块 + +| 路径 | 数量 | 当前文档覆盖 | 备注 | +| --- | ---: | --- | --- | +| `shared/` | 161 | `docs/tech/shared-api.md`、功能文档、`docs/tech/connection-modes.md`、`docs/tech/state-storage.md` | DTO、API 契约、relay/local backend、存储、平台能力。 | +| `composeApp/` | 158 | `docs/tech/modules.md`、功能文档 | Compose Multiplatform UI、ViewModel、导航、Web 资源、桌面打包。 | +| `server/` | 127 | `docs/tech/server-routes.md`、`docs/tech/architecture.md`、功能文档 | Ktor 路由、会话和缓存服务、上游客户端、指标、公告、版本检查。 | +| `docs/` | 38 | `docs/index.md` 与 VitePress sidebar | 公共文档、功能页、技术页、公告、审查文件、静态二维码图片。 | +| `androidApp/` | 23 | `README.md`、`docs/tech/modules.md` | Android 壳工程、manifest、资源、签名和版本配置。 | +| `iosApp/` | 14 | `README.md`、`docs/tech/modules.md` | SwiftUI 壳工程、Xcode 项目、图标和 plist。 | +| `buildSrc/` | 3 | `docs/tech/modules.md`、`docs/tech/testing.md`、`docs/tech/release-deployment.md` | 自定义 bhpan Gradle 任务及测试。 | +| `gradle/` | 3 | `docs/tech/configuration.md` | 版本目录和 Gradle wrapper。 | + +## Source Set 计数 + +| 分组 | 数量 | +| --- | ---: | +| `shared/src/commonMain` | 72 | +| `shared/src/commonTest` | 39 | +| `shared/src/androidMain` | 9 | +| `shared/src/iosMain` | 9 | +| `shared/src/jsMain` | 9 | +| `shared/src/jvmMain` | 9 | +| `shared/src/jvmTest` | 4 | +| `shared/src/wasmJsMain` | 9 | +| `composeApp/src/commonMain` | 99 | +| `composeApp/src/commonTest` | 22 | +| `composeApp/src/androidMain` | 3 | +| `composeApp/src/iosMain` | 4 | +| `composeApp/src/jsMain` | 3 | +| `composeApp/src/jvmMain` | 5 | +| `composeApp/src/wasmJsMain` | 3 | +| `composeApp/src/webMain` | 13 | +| `server/src/main` | 79 | +| `server/src/test` | 47 | + +## GitHub Workflows + +当前 workflow 文件: + +- `.github/workflows/docs.yml` +- `.github/workflows/format.yml` +- `.github/workflows/release.yml` +- `.github/workflows/test.yml` + +已清理的过期记录: + +- `.github/workflows/upload.yml` 当前不存在,不能作为现行 workflow 记录。 + +## 文档文件 + +| 路径 | 状态 | +| --- | --- | +| `docs/index.md` | 文档站首页。 | +| `docs/.vitepress/config.mts` | VitePress 导航和侧边栏。 | +| `docs/features/index.md` | 功能总览。 | +| `docs/features/auth-and-connection.md` | 登录与连接模式。 | +| `docs/features/schedule-and-exam.md` | 课表与考试。 | +| `docs/features/grades.md` | 成绩查询。 | +| `docs/features/bykc.md` | 博雅课程。 | +| `docs/features/classroom.md` | 空教室查询。 | +| `docs/features/spoc.md` | SPOC 作业。 | +| `docs/features/judge.md` | 希冀作业。 | +| `docs/features/libbook.md` | 图书馆座位。 | +| `docs/features/signin.md` | 课程签到。 | +| `docs/features/cgyy.md` | 研讨室预约。 | +| `docs/features/ygdk.md` | 阳光打卡。 | +| `docs/features/evaluation.md` | 自动评教。 | +| `docs/tech/architecture.md` | 架构总览。 | +| `docs/tech/modules.md` | 模块职责。 | +| `docs/tech/shared-api.md` | 共享 API 与契约。 | +| `docs/tech/connection-modes.md` | 连接模式规则。 | +| `docs/tech/server-routes.md` | Ktor 路由。 | +| `docs/tech/state-storage.md` | 客户端和服务端状态、缓存。 | +| `docs/tech/configuration.md` | 本地、环境变量和 CI 配置。 | +| `docs/tech/testing.md` | 验证命令和 CI 质量门禁。 | +| `docs/tech/release-deployment.md` | Release、Web 和 docs 部署。 | +| `docs/tech/troubleshooting.md` | 排障指南。 | +| `docs/tech/privacy-policy.md` | 隐私边界。 | +| `docs/tech/disclaimer.md` | 第三方免责声明。 | +| `docs/announcements/index.md` | 公告维护说明。 | +| `docs/announcements/history.md` | 公告历史。 | +| `docs/changelog/index.md` | 更新日志索引。 | +| `docs/_audit/2026-06-08-full-repo-docs-review.md` | 本次审查报告。 | +| `docs/_audit/source-inventory.md` | 本清单。 | + +## 高风险源码锚点 + +| 区域 | 源码锚点 | 覆盖文档 | | --- | --- | --- | -| .env.sample | 已覆盖 | docs/tech/configuration.md | -| .gitattributes | 无需文档化 | N/A | -| .github/workflows/docs.yml | 已覆盖 | docs/tech/release-deployment.md | -| .github/workflows/format.yml | 已覆盖 | docs/tech/testing.md | -| .github/workflows/release.yml | 已覆盖 | docs/tech/release-deployment.md | -| .github/workflows/test.yml | 已覆盖 | docs/tech/testing.md | -| .github/workflows/upload.yml | 已覆盖 | docs/tech/release-deployment.md | -| .gitignore | 已覆盖 | docs/tech/configuration.md | -| androidApp/build.gradle.kts | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/AndroidManifest.xml | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/java/cn/edu/ubaa/MainActivity.kt | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/res/drawable/ic_launcher_background.xml | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 已覆盖 | docs/tech/modules.md | -| androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-hdpi/ic_launcher.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-mdpi/ic_launcher.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png | 无需文档化 | N/A | -| androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png | 无需文档化 | N/A | -| androidApp/src/main/res/values/strings.xml | 已覆盖 | docs/tech/modules.md | -| build.gradle.kts | 已覆盖 | docs/index.md | -| composeApp/build.gradle.kts | 已覆盖 | docs/tech/configuration.md | -| composeApp/compose-desktop.pro | 已覆盖 | docs/tech/modules.md | -| composeApp/icons/app.icns | 已覆盖 | docs/tech/modules.md | -| composeApp/icons/app.ico | 无需文档化 | N/A | -| composeApp/icons/app.png | 无需文档化 | N/A | -| composeApp/src/androidMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.android.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/androidMain/kotlin/cn/edu/ubaa/ui/common/util/PlatformImagePicker.android.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/androidMain/kotlin/cn/edu/ubaa/ui/theme/Typography.android.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/composeResources/drawable/app_icon.png | 无需文档化 | N/A | -| composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/composeResources/font/yahei.ttf | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/TestClock.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/AppTopBar.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/BottomNavigation.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/ReleaseNotesText.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/Sidebar.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.notAndroid.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/util/PlatformImagePicker.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/HomeBootstrapCoordinator.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/NavigationController.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/auth/AuthViewModel.kt | 已覆盖 | docs/features/auth-and-connection.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/auth/ConnectionModeSelectionScreen.kt | 已覆盖 | docs/features/auth-and-connection.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/auth/LoginScreen.kt | 已覆盖 | docs/features/auth-and-connection.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/auth/UserInfoScreen.kt | 已覆盖 | docs/features/auth-and-connection.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcChosenCoursesScreen.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcCourseDetailScreen.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcCourseFilters.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcCoursesScreen.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcHomeScreen.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcStatisticsScreen.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcTimeFormatters.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcViewModel.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyHomeScreen.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyLockCodeScreen.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyOrdersScreen.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyReserveFormScreen.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyReservePickerScreen.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyUiCommon.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/cgyy/CgyyViewModel.kt | 已覆盖 | docs/features/cgyy.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/classroom/ClassroomQueryScreen.kt | 已覆盖 | docs/features/classroom.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/classroom/ClassroomViewModel.kt | 已覆盖 | docs/features/classroom.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/evaluation/EvaluationScreen.kt | 已覆盖 | docs/features/evaluation.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/evaluation/EvaluationViewModel.kt | 已覆盖 | docs/features/evaluation.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/exam/ExamScreen.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/exam/ExamViewModel.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/grade/GradeScreen.kt | 已覆盖 | docs/features/grades.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/grade/GradeViewModel.kt | 已覆盖 | docs/features/grades.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/judge/JudgeAssignmentDetailScreen.kt | 已覆盖 | docs/features/judge.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/judge/JudgeAssignmentsScreen.kt | 已覆盖 | docs/features/judge.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/judge/JudgeViewModel.kt | 已覆盖 | docs/features/judge.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/AdvancedFeaturesScreen.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/HomeScreen.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/HomeTodo.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/OtherScreens.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/RegularFeaturesScreen.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/SettingsScreen.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/schedule/CourseDetailScreen.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/schedule/ScheduleDateLabels.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/schedule/ScheduleScreen.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/schedule/ScheduleViewModel.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/signin/SigninScreen.kt | 已覆盖 | docs/features/signin.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/signin/SigninViewModel.kt | 已覆盖 | docs/features/signin.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/splash/SplashScreen.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/spoc/SpocAssignmentDetailScreen.kt | 已覆盖 | docs/features/spoc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/spoc/SpocAssignmentsScreen.kt | 已覆盖 | docs/features/spoc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/spoc/SpocViewModel.kt | 已覆盖 | docs/features/spoc.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/ygdk/YgdkClockinFormScreen.kt | 已覆盖 | docs/features/ygdk.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/ygdk/YgdkHomeScreen.kt | 已覆盖 | docs/features/ygdk.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/ygdk/YgdkViewModel.kt | 已覆盖 | docs/features/ygdk.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Typography.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/AppStartupDialogStateTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ComposeAppCommonTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/AuthViewModelInitializeAppTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/AuthViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/CgyyScreenLogicTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/CgyyViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/ClassroomViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/common/components/ReleaseNotesLinkParserTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/GradeScreenLogicTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/GradeViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/HomeTodoTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/JudgeViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/navigation/HomeBootstrapCoordinatorTest.kt | 已覆盖 | docs/features/index.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcCourseFiltersTest.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/screens/bykc/BykcTimeFormattersTest.kt | 已覆盖 | docs/features/bykc.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/screens/judge/JudgeAssignmentDisplayTest.kt | 已覆盖 | docs/features/judge.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/screens/schedule/ScheduleDateLabelsTest.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/SpocViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/YgdkViewModelTest.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/iosMain/kotlin/cn/edu/ubaa/MainViewController.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/iosMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.ios.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/iosMain/kotlin/cn/edu/ubaa/ui/common/util/PlatformImagePicker.ios.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/iosMain/kotlin/cn/edu/ubaa/ui/theme/Typography.ios.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jsMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.js.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jsMain/kotlin/cn/edu/ubaa/ui/common/util/PlatformImagePicker.js.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jsMain/kotlin/cn/edu/ubaa/ui/theme/Typography.js.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jvmMain/kotlin/cn/edu/ubaa/main.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jvmMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.jvm.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jvmMain/kotlin/cn/edu/ubaa/ui/common/util/PlatformImagePicker.jvm.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jvmMain/kotlin/cn/edu/ubaa/ui/theme/Typography.jvm.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/jvmMain/resources/app-icon.png | 无需文档化 | N/A | -| composeApp/src/wasmJsMain/kotlin/cn/edu/ubaa/ui/common/util/BackHandlerCompat.wasmJs.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/wasmJsMain/kotlin/cn/edu/ubaa/ui/common/util/PlatformImagePicker.wasmJs.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/wasmJsMain/kotlin/cn/edu/ubaa/ui/theme/Typography.wasmJs.kt | 已覆盖 | docs/tech/modules.md | -| composeApp/src/webMain/kotlin/cn/edu/ubaa/main.kt | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/src/webMain/resources/apple-touch-icon.png | 无需文档化 | N/A | -| composeApp/src/webMain/resources/favicon-16.png | 无需文档化 | N/A | -| composeApp/src/webMain/resources/favicon-32.png | 无需文档化 | N/A | -| composeApp/src/webMain/resources/favicon.ico | 无需文档化 | N/A | -| composeApp/src/webMain/resources/index.html | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/src/webMain/resources/manifest.json | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/src/webMain/resources/pwa-icon-192.png | 无需文档化 | N/A | -| composeApp/src/webMain/resources/pwa-icon-512.png | 无需文档化 | N/A | -| composeApp/src/webMain/resources/pwa-icon.svg | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/src/webMain/resources/styles.css | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/src/webMain/resources/sw.js | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/src/webMain/resources/update.html | 已覆盖 | docs/tech/release-deployment.md | -| composeApp/webpack.config.d/watch.js | 已覆盖 | docs/tech/configuration.md | -| docs/_audit/source-inventory.md | 已覆盖 | docs/index.md | -| docs/.vitepress/config.mts | 已覆盖 | docs/index.md | -| docs/announcements/history.md | 已覆盖 | docs/index.md | -| docs/announcements/index.md | 已覆盖 | docs/index.md | -| docs/changelog/index.md | 已覆盖 | docs/index.md | -| docs/features/auth-and-connection.md | 已覆盖 | docs/index.md | -| docs/features/bykc.md | 已覆盖 | docs/index.md | -| docs/features/cgyy.md | 已覆盖 | docs/index.md | -| docs/features/classroom.md | 已覆盖 | docs/index.md | -| docs/features/evaluation.md | 已覆盖 | docs/index.md | -| docs/features/grades.md | 已覆盖 | docs/index.md | -| docs/features/index.md | 已覆盖 | docs/index.md | -| docs/features/judge.md | 已覆盖 | docs/index.md | -| docs/features/schedule-and-exam.md | 已覆盖 | docs/index.md | -| docs/features/signin.md | 已覆盖 | docs/index.md | -| docs/features/spoc.md | 已覆盖 | docs/index.md | -| docs/features/ygdk.md | 已覆盖 | docs/index.md | -| docs/index.md | 已覆盖 | docs/index.md | -| docs/tech/architecture.md | 已覆盖 | docs/index.md | -| docs/tech/configuration.md | 已覆盖 | docs/index.md | -| docs/tech/connection-modes.md | 已覆盖 | docs/index.md | -| docs/tech/modules.md | 已覆盖 | docs/index.md | -| docs/tech/release-deployment.md | 已覆盖 | docs/index.md | -| docs/tech/server-routes.md | 已覆盖 | docs/index.md | -| docs/tech/shared-api.md | 已覆盖 | docs/index.md | -| docs/tech/state-storage.md | 已覆盖 | docs/index.md | -| docs/tech/testing.md | 已覆盖 | docs/index.md | -| docs/tech/troubleshooting.md | 已覆盖 | docs/index.md | -| gradle.properties | 已覆盖 | docs/tech/configuration.md | -| gradle/libs.versions.toml | 已覆盖 | docs/tech/configuration.md | -| gradle/wrapper/gradle-wrapper.jar | 无需文档化 | N/A | -| gradle/wrapper/gradle-wrapper.properties | 已覆盖 | docs/tech/configuration.md | -| gradlew | 已覆盖 | docs/tech/configuration.md | -| gradlew.bat | 已覆盖 | docs/tech/configuration.md | -| iosApp/Configuration/Config.xcconfig | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp.xcodeproj/project.pbxproj | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png | 无需文档化 | N/A | -| iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png | 无需文档化 | N/A | -| iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png | 无需文档化 | N/A | -| iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/Assets.xcassets/Contents.json | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/ContentView.swift | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/Info.plist | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/iOSApp.swift | 已覆盖 | docs/tech/modules.md | -| iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json | 已覆盖 | docs/tech/modules.md | -| LICENSE | 无需文档化 | N/A | -| package-lock.json | 已覆盖 | docs/tech/configuration.md | -| package.json | 已覆盖 | docs/tech/configuration.md | -| README.md | 已覆盖 | docs/index.md | -| server/build.gradle.kts | 已覆盖 | docs/tech/configuration.md | -| server/src/main/kotlin/cn/edu/ubaa/announcement/AnnouncementRoutes.kt | 已覆盖 | docs/announcements/index.md | -| server/src/main/kotlin/cn/edu/ubaa/announcement/AnnouncementService.kt | 已覆盖 | docs/announcements/index.md | -| server/src/main/kotlin/cn/edu/ubaa/Application.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/api/AuthRoutes.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/api/AuthService.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/api/Exceptions.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/api/UserFacingErrors.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/config/AuthConfig.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/infra/DistributedLockManager.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/infra/RedisRuntime.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/jwt/JwtAuth.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/portal/AcademicPortalSupport.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/portal/AcademicPortalWarmupCoordinator.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/session/PreLoginPersistence.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/session/RedisCookieStorage.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/session/RedisCookieStorageFactory.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/session/RedisSessionStore.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/session/RefreshTokenStore.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/session/SessionManager.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/upstream/ByxtService.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/auth/upstream/CasParser.kt | 已覆盖 | docs/features/auth-and-connection.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcClient.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcCrypto.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcDebugConfig.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcExceptions.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcModels.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcRoutes.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/bykc/BykcService.kt | 已覆盖 | docs/features/bykc.md | -| server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyCaptchaSolver.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyRoutes.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyService.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyySigner.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyZhjsClient.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/main/kotlin/cn/edu/ubaa/classroom/ClassroomClient.kt | 已覆盖 | docs/features/classroom.md | -| server/src/main/kotlin/cn/edu/ubaa/classroom/ClassroomRoutes.kt | 已覆盖 | docs/features/classroom.md | -| server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationClient.kt | 已覆盖 | docs/features/evaluation.md | -| server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationRoutes.kt | 已覆盖 | docs/features/evaluation.md | -| server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationService.kt | 已覆盖 | docs/features/evaluation.md | -| server/src/main/kotlin/cn/edu/ubaa/exam/ExamRoutes.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| server/src/main/kotlin/cn/edu/ubaa/exam/ExamService.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| server/src/main/kotlin/cn/edu/ubaa/grade/GradeRoutes.kt | 已覆盖 | docs/features/grades.md | -| server/src/main/kotlin/cn/edu/ubaa/grade/GradeService.kt | 已覆盖 | docs/features/grades.md | -| server/src/main/kotlin/cn/edu/ubaa/health/HealthSupport.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/judge/JudgeClient.kt | 已覆盖 | docs/features/judge.md | -| server/src/main/kotlin/cn/edu/ubaa/judge/JudgeRoutes.kt | 已覆盖 | docs/features/judge.md | -| server/src/main/kotlin/cn/edu/ubaa/judge/JudgeService.kt | 已覆盖 | docs/features/judge.md | -| server/src/main/kotlin/cn/edu/ubaa/judge/JudgeSupport.kt | 已覆盖 | docs/features/judge.md | -| server/src/main/kotlin/cn/edu/ubaa/metrics/FunctionCounterBindings.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/metrics/GaugeBindings.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/metrics/LoginMetrics.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/metrics/Observability.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/schedule/ScheduleRoutes.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| server/src/main/kotlin/cn/edu/ubaa/schedule/ScheduleService.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| server/src/main/kotlin/cn/edu/ubaa/ServerRuntimeConfig.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/signin/SigninClient.kt | 已覆盖 | docs/features/signin.md | -| server/src/main/kotlin/cn/edu/ubaa/signin/SigninRoutes.kt | 已覆盖 | docs/features/signin.md | -| server/src/main/kotlin/cn/edu/ubaa/signin/SigninService.kt | 已覆盖 | docs/features/signin.md | -| server/src/main/kotlin/cn/edu/ubaa/spoc/SpocClient.kt | 已覆盖 | docs/features/spoc.md | -| server/src/main/kotlin/cn/edu/ubaa/spoc/SpocRoutes.kt | 已覆盖 | docs/features/spoc.md | -| server/src/main/kotlin/cn/edu/ubaa/spoc/SpocService.kt | 已覆盖 | docs/features/spoc.md | -| server/src/main/kotlin/cn/edu/ubaa/spoc/SpocSupport.kt | 已覆盖 | docs/features/spoc.md | -| server/src/main/kotlin/cn/edu/ubaa/user/UserRoutes.kt | 已覆盖 | docs/tech/modules.md | -| server/src/main/kotlin/cn/edu/ubaa/user/UserService.kt | 已覆盖 | docs/tech/modules.md | -| server/src/main/kotlin/cn/edu/ubaa/utils/HeadlessImageSupport.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/utils/HttpClients.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/utils/JwtUtil.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/utils/UpstreamTimeouts.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/utils/VpnCipher.kt | 已覆盖 | docs/tech/server-routes.md | -| server/src/main/kotlin/cn/edu/ubaa/version/AppVersionRoutes.kt | 已覆盖 | docs/changelog/index.md | -| server/src/main/kotlin/cn/edu/ubaa/version/AppVersionService.kt | 已覆盖 | docs/changelog/index.md | -| server/src/main/kotlin/cn/edu/ubaa/ygdk/YgdkClient.kt | 已覆盖 | docs/features/ygdk.md | -| server/src/main/kotlin/cn/edu/ubaa/ygdk/YgdkException.kt | 已覆盖 | docs/features/ygdk.md | -| server/src/main/kotlin/cn/edu/ubaa/ygdk/YgdkRoutes.kt | 已覆盖 | docs/features/ygdk.md | -| server/src/main/kotlin/cn/edu/ubaa/ygdk/YgdkService.kt | 已覆盖 | docs/features/ygdk.md | -| server/src/main/resources/logback.xml | 已覆盖 | docs/tech/configuration.md | -| server/src/test/kotlin/cn/edu/ubaa/announcement/AnnouncementRoutesTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/announcement/AnnouncementServiceTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/ApplicationMetricsTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/ApplicationTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/api/AuthRoutesTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/api/AuthServiceConcurrencyTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/api/AuthServiceLoginMetricsTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/api/AuthServiceResourceTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/config/AuthConfigTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/jwt/JwtTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/portal/AcademicPortalSupportTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/session/InMemorySessionSupport.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/session/SessionManagerMetricsTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/auth/upstream/CasParserTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/bykc/BykcClientTest.kt | 已覆盖 | docs/features/bykc.md | -| server/src/test/kotlin/cn/edu/ubaa/bykc/BykcCryptoTest.kt | 已覆盖 | docs/features/bykc.md | -| server/src/test/kotlin/cn/edu/ubaa/bykc/BykcModelsTest.kt | 已覆盖 | docs/features/bykc.md | -| server/src/test/kotlin/cn/edu/ubaa/bykc/BykcRoutesTest.kt | 已覆盖 | docs/features/bykc.md | -| server/src/test/kotlin/cn/edu/ubaa/bykc/BykcServiceCacheTest.kt | 已覆盖 | docs/features/bykc.md | -| server/src/test/kotlin/cn/edu/ubaa/bykc/BykcServiceDetailTest.kt | 已覆盖 | docs/features/bykc.md | -| server/src/test/kotlin/cn/edu/ubaa/cgyy/CgyyCaptchaSolverTest.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/test/kotlin/cn/edu/ubaa/cgyy/CgyyRoutesTest.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/test/kotlin/cn/edu/ubaa/cgyy/CgyyServiceTest.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/test/kotlin/cn/edu/ubaa/cgyy/CgyySignerTest.kt | 已覆盖 | docs/features/cgyy.md | -| server/src/test/kotlin/cn/edu/ubaa/exam/ExamRoutesTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/grade/GradeRoutesTest.kt | 已覆盖 | docs/features/grades.md | -| server/src/test/kotlin/cn/edu/ubaa/grade/GradeServiceTest.kt | 已覆盖 | docs/features/grades.md | -| server/src/test/kotlin/cn/edu/ubaa/health/HealthSupportTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/judge/JudgeClientTest.kt | 已覆盖 | docs/features/judge.md | -| server/src/test/kotlin/cn/edu/ubaa/judge/JudgeRealIntegrationTest.kt | 已覆盖 | docs/features/judge.md | -| server/src/test/kotlin/cn/edu/ubaa/judge/JudgeRoutesTest.kt | 已覆盖 | docs/features/judge.md | -| server/src/test/kotlin/cn/edu/ubaa/judge/JudgeServiceTest.kt | 已覆盖 | docs/features/judge.md | -| server/src/test/kotlin/cn/edu/ubaa/judge/JudgeSupportTest.kt | 已覆盖 | docs/features/judge.md | -| server/src/test/kotlin/cn/edu/ubaa/metrics/LoginStatsStoreTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/metrics/ObservabilityTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/schedule/ScheduleRoutesTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/signin/SigninClientTest.kt | 已覆盖 | docs/features/signin.md | -| server/src/test/kotlin/cn/edu/ubaa/spoc/SpocClientTest.kt | 已覆盖 | docs/features/spoc.md | -| server/src/test/kotlin/cn/edu/ubaa/spoc/SpocRoutesTest.kt | 已覆盖 | docs/features/spoc.md | -| server/src/test/kotlin/cn/edu/ubaa/spoc/SpocServiceTest.kt | 已覆盖 | docs/features/spoc.md | -| server/src/test/kotlin/cn/edu/ubaa/spoc/SpocSupportTest.kt | 已覆盖 | docs/features/spoc.md | -| server/src/test/kotlin/cn/edu/ubaa/utils/VpnCipherTest.kt | 已覆盖 | docs/features/signin.md | -| server/src/test/kotlin/cn/edu/ubaa/version/AppVersionServiceTest.kt | 已覆盖 | docs/tech/testing.md | -| server/src/test/kotlin/cn/edu/ubaa/ygdk/YgdkRoutesTest.kt | 已覆盖 | docs/features/ygdk.md | -| server/src/test/kotlin/cn/edu/ubaa/ygdk/YgdkServiceTest.kt | 已覆盖 | docs/features/ygdk.md | -| settings.gradle.kts | 已覆盖 | docs/index.md | -| shared/build.gradle.kts | 已覆盖 | docs/tech/configuration.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/ApiClient.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/PlatformAesCbcNoPadding.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/PlatformAesCfbNoPadding.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/PlatformAesEcbPkcs5Padding.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/PlatformImageRasterDecoder.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/PlatformMd5Hex.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/api/PlatformRsaPkcs1Encrypt.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/androidMain/kotlin/cn/edu/ubaa/Platform.android.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/AnnouncementService.kt | 已覆盖 | docs/features/auth-and-connection.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/AuthApi.kt | 已覆盖 | docs/features/auth-and-connection.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/LoginStatsReporter.kt | 已覆盖 | docs/features/auth-and-connection.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/NetworkUtils.kt | 已覆盖 | docs/features/auth-and-connection.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/UpdateService.kt | 已覆盖 | docs/features/auth-and-connection.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/ConnectionRuntime.kt | 已覆盖 | docs/tech/connection-modes.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiClient.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiFactory.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiService.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/BykcApi.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/CgyyApi.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/ClassroomApi.kt | 已覆盖 | docs/features/classroom.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/EvaluationService.kt | 已覆盖 | docs/features/evaluation.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/GradeApi.kt | 已覆盖 | docs/features/grades.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/JudgeApi.kt | 已覆盖 | docs/features/judge.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/ScheduleApi.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/SigninApi.kt | 已覆盖 | docs/features/signin.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/SpocApi.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/YgdkApi.kt | 已覆盖 | docs/features/ygdk.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/SigninLoginNameSupport.kt | 已覆盖 | docs/features/signin.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalBykcApi.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalBykcCrypto.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalCgyyApi.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalCgyyCaptchaSupport.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalCgyySigner.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalClassroomApi.kt | 已覆盖 | docs/features/classroom.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalConnectionAuth.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalEvaluationService.kt | 已覆盖 | docs/features/evaluation.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalGradeApi.kt | 已覆盖 | docs/features/grades.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalJudgeApi.kt | 已覆盖 | docs/features/judge.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalScheduleApi.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSigninApi.kt | 已覆盖 | docs/features/signin.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSpocApi.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSpocSupport.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalWebVpnSupport.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalYgdkApi.kt | 已覆盖 | docs/features/ygdk.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/plantform/PlatformAesCbcNoPadding.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/plantform/PlatformAesCfbNoPadding.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/plantform/PlatformAesEcbPkcs5Padding.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/plantform/PlatformImageRasterDecoder.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/plantform/PlatformMd5Hex.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/plantform/PlatformRsaPkcs1Encrypt.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/ResettableSharedInstance.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/AnnouncementReadStore.kt | 已覆盖 | docs/tech/state-storage.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/BykcCourseFilterStore.kt | 已覆盖 | docs/tech/state-storage.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/CgyyReservationFormStore.kt | 已覆盖 | docs/tech/state-storage.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/CredentialStore.kt | 已覆盖 | docs/tech/state-storage.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/TokenStore.kt | 已覆盖 | docs/tech/state-storage.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/AppInfo.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/Greeting.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Auth.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Bykc.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/BykcSerialization.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Cgyy.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Classroom.kt | 已覆盖 | docs/features/classroom.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Exam.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Grade.kt | 已覆盖 | docs/features/grades.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Judge.kt | 已覆盖 | docs/features/judge.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Schedule.kt | 已覆盖 | docs/features/schedule-and-exam.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Signin.kt | 已覆盖 | docs/features/signin.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Spoc.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/UserInfo.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Ygdk.kt | 已覆盖 | docs/features/ygdk.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/model/evaluation/EvaluationModel.kt | 已覆盖 | docs/features/evaluation.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/Platform.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonMain/kotlin/cn/edu/ubaa/repository/TermRepository.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/AnnouncementReadStoreTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/AnnouncementServiceTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/ApiFactoryDispatchTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/AuthServiceTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/BykcCourseFilterStoreTest.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/CgyyApiTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/ConnectionRuntimeTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/JudgeApiTest.kt | 已覆盖 | docs/features/judge.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalAuthServiceBackendTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalAuthSessionStoreTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalBykcApiBackendTest.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalBykcCryptoTest.kt | 已覆盖 | docs/features/bykc.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyyApiBackendTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyyCaptchaSolverTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyySignerTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalClassroomApiBackendTest.kt | 已覆盖 | docs/features/classroom.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalConnectionTestSupport.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCookieStoreTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalEvaluationServiceBackendTest.kt | 已覆盖 | docs/features/evaluation.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalJudgeApiBackendTest.kt | 已覆盖 | docs/features/judge.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalScheduleApiBackendTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSigninApiBackendTest.kt | 已覆盖 | docs/features/signin.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/SigninLoginNameSupportTest.kt | 已覆盖 | docs/features/signin.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSpocApiBackendTest.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSpocSupportTest.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalWebVpnSupportTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalYgdkApiBackendTest.kt | 已覆盖 | docs/features/ygdk.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/NetworkUtilsTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/SpocApiTest.kt | 已覆盖 | docs/features/spoc.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/UpdateServiceTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/api/YgdkApiTest.kt | 已覆盖 | docs/features/ygdk.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/model/dto/CgyyOrderCancellationTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/model/dto/CgyyOrderDateDisplayTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/model/dto/CgyyOrderStatusTest.kt | 已覆盖 | docs/features/cgyy.md | -| shared/src/commonTest/kotlin/cn/edu/ubaa/SharedCommonTest.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/ApiClient.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/PlatformAesCbcNoPadding.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/PlatformAesCfbNoPadding.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/PlatformAesEcbPkcs5Padding.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/PlatformImageRasterDecoder.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/PlatformMd5Hex.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/api/PlatformRsaPkcs1Encrypt.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/iosMain/kotlin/cn/edu/ubaa/Platform.ios.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/ApiClient.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/PlatformAesCbcNoPadding.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/PlatformAesCfbNoPadding.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/PlatformAesEcbPkcs5Padding.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/PlatformImageRasterDecoder.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/PlatformMd5Hex.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/api/PlatformRsaPkcs1Encrypt.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jsMain/kotlin/cn/edu/ubaa/Platform.js.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/ApiClient.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/PlatformAesCbcNoPadding.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/PlatformAesCfbNoPadding.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/PlatformAesEcbPkcs5Padding.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/PlatformImageRasterDecoder.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/PlatformMd5Hex.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/api/PlatformRsaPkcs1Encrypt.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmMain/kotlin/cn/edu/ubaa/Platform.jvm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/jvmTest/kotlin/cn/edu/ubaa/api/LocalJudgeRealIntegrationTest.kt | 已覆盖 | docs/features/judge.md | -| shared/src/jvmTest/kotlin/cn/edu/ubaa/api/LocalSigninRealIntegrationTest.kt | 已覆盖 | docs/features/signin.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/ApiClient.wasm.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/PlatformAesCbcNoPadding.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/PlatformAesCfbNoPadding.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/PlatformAesEcbPkcs5Padding.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/PlatformImageRasterDecoder.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/PlatformMd5Hex.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/api/PlatformRsaPkcs1Encrypt.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | -| shared/src/wasmJsMain/kotlin/cn/edu/ubaa/Platform.wasmJs.kt | 已覆盖 | docs/tech/shared-api.md | +| 连接模式 | `shared/src/commonMain/kotlin/cn/edu/ubaa/api/ConnectionRuntime.kt`、`shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiFactory.kt`、`shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalWebVpnSupport.kt` | `docs/features/auth-and-connection.md`、`docs/tech/connection-modes.md` | +| CGYY | `shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalCgyyApi.kt`、`server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyRoutes.kt`、`server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyService.kt`、`server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyZhjsClient.kt` | `docs/features/cgyy.md`、`docs/tech/connection-modes.md` | +| Judge | `shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalJudgeApi.kt`、`server/src/main/kotlin/cn/edu/ubaa/judge/JudgeRoutes.kt`、`server/src/main/kotlin/cn/edu/ubaa/judge/JudgeService.kt`、`server/src/main/kotlin/cn/edu/ubaa/judge/JudgeSupport.kt` | `docs/features/judge.md` | +| LibBook | `shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/LibBookApi.kt`、`shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalLibBookApi.kt`、`server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookRoutes.kt`、`server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookService.kt`、`composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/libbook/` | `docs/features/libbook.md`、`docs/tech/server-routes.md`、`docs/tech/shared-api.md` | +| 版本检查 | `shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/UpdateService.kt`、`server/src/main/kotlin/cn/edu/ubaa/version/AppVersionService.kt`、`server/src/main/kotlin/cn/edu/ubaa/version/AppVersionRoutes.kt` | `docs/tech/shared-api.md`、`docs/tech/configuration.md`、`docs/changelog/index.md` | +| 发布与 docs 部署 | `.github/workflows/release.yml`、`.github/workflows/docs.yml`、`build.gradle.kts`、`buildSrc/src/main/kotlin/cn/edu/ubaa/gradle/UploadLatestReleaseToBhpanTask.kt` | `docs/tech/release-deployment.md`、`docs/tech/testing.md` | + +## 备注 + +- 当前仓库不存在 `harmonyApp/`。 +- 当前仓库不存在 `.github/workflows/upload.yml`;Web Wasm 部署由 `.github/workflows/release.yml` 处理。 +- `docs/.vitepress/dist/` 和 `docs/.vitepress/cache/` 是生成产物,按计划排除。 +- `local.properties` 是本机私密配置,按计划排除。 diff --git a/docs/announcements/2026-06-03-001.md b/docs/announcements/2026-06-03-001.md new file mode 100644 index 00000000..4a7c2b4b --- /dev/null +++ b/docs/announcements/2026-06-03-001.md @@ -0,0 +1,19 @@ +--- +outline: deep +--- + +# 关于中转模式不稳定的公告 + +**公告 ID:** `2026-06-03-001` · **发布日期:** 2026-06-03 + +--- + +近期学校频繁改动校园网和SSO的访问策略,中转模式可能会长时间不稳定,建议不要使用网页端和中转模式,可以下载APP使用直连或webvpn模式。 + +::: tip IOS下载 +点击前往App Store: [https://apps.apple.com/cn/app/ubaa/id6768861821](https://apps.apple.com/cn/app/ubaa/id6768861821) +::: + +::: tip 其他平台下载地址 +点击下载: [https://bhpan.buaa.edu.cn/link/AA61B7EDC6F2BC4AF5B040FAF0563AD160](https://bhpan.buaa.edu.cn/link/AA61B7EDC6F2BC4AF5B040FAF0563AD160) +::: diff --git a/docs/announcements/history.md b/docs/announcements/history.md index f070337b..ae9053cf 100644 --- a/docs/announcements/history.md +++ b/docs/announcements/history.md @@ -2,6 +2,7 @@ 所有公告按时间倒序列出,点击标题查看详情。 +- 2026-06-03 | `2026-06-03-001` | 关于中转模式不稳定的公告 | [查看详情](./2026-06-03-001.md) - 2026-05-20 | `2026-05-20-001` | UBAA IOS APP | [查看详情](./2026-05-20-001.md) - 2026-05-11 | `2026-05-11-001` | UBAA 开启众筹 | [查看详情](./2026-05-11-001.md) - 2026-05-09 | `2026-05-09-001` | 官方用户群 | [查看详情](./2026-05-09-001.md) diff --git a/docs/announcements/index.md b/docs/announcements/index.md index db266a21..c7a445bb 100644 --- a/docs/announcements/index.md +++ b/docs/announcements/index.md @@ -21,7 +21,7 @@ UBAA 通过应用内弹窗通知用户查看公告,公告详情统一发布在 "title": "官方用户群", "content": "加入官方 QQ/微信群,获取最新消息和技术支持!", "confirmText": "查看详情", - "linkUrl": "https://docs.buaa.team/announcements/2026-05-09-001" + "linkUrl": "https://www.buaa.team/announcements/2026-05-09-001" } ``` diff --git a/docs/features/auth-and-connection.md b/docs/features/auth-and-connection.md index 69ab76e9..53ebb71c 100644 --- a/docs/features/auth-and-connection.md +++ b/docs/features/auth-and-connection.md @@ -4,7 +4,8 @@ ## 用户能力 -- 支持直连模式、WebVPN 模式和服务器中转模式。 +- Android、iOS、Desktop 支持直连模式、WebVPN 模式和服务器中转模式。 +- Web/Wasm 端只暴露服务器中转模式,启动时会自动保存 `SERVER_RELAY`。 - 支持验证码获取、预登录、登录、登录状态检查、Token 刷新和退出登录。 - 切换连接模式时会清理 Token、ClientId、本地会话、Cookie、本地上游客户端和学期缓存,避免跨模式复用旧状态。 - Web/Wasm 平台只暴露服务器中转;Android、iOS、Desktop 可按平台能力使用本地连接模式。 diff --git a/docs/features/cgyy.md b/docs/features/cgyy.md index a58a3f69..4843f552 100644 --- a/docs/features/cgyy.md +++ b/docs/features/cgyy.md @@ -1,6 +1,6 @@ # 研讨室预约 -研讨室预约模块覆盖场地查询、预约目的、日期时段、预约提交、订单列表、取消订单和门锁码。直连模式和服务器中转都要处理验证码、签名和上游接口异常。 +研讨室预约模块覆盖场地查询、预约目的、日期时段、预约提交、订单列表、取消订单和门锁码。预约提交和取消订单属于写操作,直连本地实现与服务器中转都要处理验证码、签名和上游接口异常。 ## 用户能力 @@ -13,7 +13,8 @@ - UI 分为首页、空间选择、预约表单、订单列表和门锁码页。 - `CgyyViewModel` 管理预约流程状态、表单存储和订单加载。 -- `CgyyApiBackend` 是 shared 层契约;`LocalCgyyApiBackend` 实现直连/WebVPN。 +- `CgyyApiBackend` 是 shared 层契约;`LocalCgyyApiBackend` 是直连/WebVPN 模式下的本地 backend。 +- 当前 `localCgyyUpstreamUrl()` 对 CGYY 始终返回原 URL,因此本地 CGYY 即使从 WebVPN 模式分发进入,也不会把 CGYY 上游 URL 包装成 WebVPN 地址。 - `CgyyService` 和 `CgyyZhjsClient` 处理服务器中转、上游登录、验证码、目的类型和订单操作。 - 预约类型为空或解析失败时,本地实现应保留 fallback 目的类型,避免直连模式无法选择预约类型。 diff --git a/docs/features/index.md b/docs/features/index.md index 39ef743a..f6bbde51 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -10,10 +10,10 @@ UBAA 把常用 BUAA 校园服务集中到一个跨平台客户端中。普通功 - [空教室查询](/features/classroom):按校区、日期、节次查询空闲教室。 - [SPOC 作业](/features/spoc):当前学期作业列表、提交状态、截止时间和详情。 - [希冀作业](/features/judge):聚合希冀课程作业、提交状态、分数和题目明细。 +- [图书馆座位](/features/libbook):查询楼馆、分区、座位,提交座位预约并管理预约记录。 ## 高级功能 -- [课程签到](/features/signin):查询当日签到并提交签到动作。 - [研讨室预约](/features/cgyy):查询场地、预约时段、提交预约、查看订单和门锁码。 - [阳光打卡](/features/ygdk):查看体育活动完成情况、历史记录并提交打卡。 - [自动评教](/features/evaluation):读取待评课程并批量提交默认评教结果。 @@ -21,7 +21,7 @@ UBAA 把常用 BUAA 校园服务集中到一个跨平台客户端中。普通功 ## 横切能力 - [登录与连接模式](/features/auth-and-connection):统一登录、Token 刷新、验证码和直连/WebVPN/服务器中转选择。 -- 首页待办会聚合 SPOC、希冀和研讨室预约,避免用户进入多个模块后才能看到未完成事项。 +- 首页待办会聚合博雅、SPOC、希冀、研讨室、签到和阳光打卡事项,避免用户进入多个模块后才能看到未完成事项。 - 公告和版本检查由服务端公开接口提供,客户端启动后按返回内容展示。 ## 来源文件 diff --git a/docs/features/libbook.md b/docs/features/libbook.md new file mode 100644 index 00000000..245fcb24 --- /dev/null +++ b/docs/features/libbook.md @@ -0,0 +1,49 @@ +# 图书馆座位 + +图书馆座位模块覆盖楼馆查询、分区查询、座位查询、座位预约、预约列表和取消预约。预约和取消预约属于写操作,审查或排障时不要用真实账号执行提交,除非用户明确授权。 + +## 用户能力 + +- 按日期查看可用楼馆和分区。 +- 查看分区详情、座位列表和可预约时间段。 +- 选择座位、日期、开始时间和结束时间后提交预约。 +- 查看个人预约记录。 +- 取消已有座位预约。 + +## 技术路径 + +- `RegularFeaturesScreen` 提供“图书馆座位”普通功能入口。 +- `MainAppScreen` 注册 `LIBBOOK_HOME`、`LIBBOOK_RESERVE`、`LIBBOOK_BOOKINGS` 三个页面。 +- `LibBookViewModel` 管理楼馆、分区、座位、预约和预约记录状态。 +- `LibBookApiBackend` 是 shared 层契约;`RelayLibBookApiBackend` 访问 server;`LocalLibBookApiBackend` 在本地连接模式下访问图书馆上游。 +- `LibBookService` 和 `LibBookClient` 处理服务器中转、CAS 登录、上游 token、超时和错误映射。 + +## 接口 + +- `GET /api/v1/libbook/libraries` +- `GET /api/v1/libbook/areas` +- `GET /api/v1/libbook/areas/{areaId}` +- `GET /api/v1/libbook/areas/{areaId}/seats` +- `POST /api/v1/libbook/bookings` +- `GET /api/v1/libbook/reservations` +- `POST /api/v1/libbook/bookings/{bookingId}/cancel` + +## 维护重点 + +- `POST /api/v1/libbook/bookings` 和 `POST /api/v1/libbook/bookings/{bookingId}/cancel` 会改变真实预约状态,只能在明确授权后做 live probe。 +- 本地实现和服务端实现都需要保留 `libbook_auth_failed`、`libbook_not_found`、`libbook_seat_unavailable` 等稳定错误语义。 +- 图书馆上游返回结构变化时,优先补充 `LibBookRoutesTest`、`LibBookServiceTest` 或本地 backend 测试,再改 parser。 + +## 来源文件 + +- `composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/libbook/LibBookHomeScreen.kt` +- `composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/libbook/LibBookReserveScreen.kt` +- `composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/libbook/LibBookBookingsScreen.kt` +- `composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/libbook/LibBookViewModel.kt` +- `composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/RegularFeaturesScreen.kt` +- `shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/LibBookApi.kt` +- `shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalLibBookApi.kt` +- `shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/LibBook.kt` +- `server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookRoutes.kt` +- `server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookService.kt` +- `server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookClient.kt` diff --git a/docs/index.md b/docs/index.md index 124b1965..11a12ab1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: UBAA text: 智慧北航 Remake - tagline: 面向北航学生的跨平台校园服务聚合客户端,一站式覆盖课表、考试、成绩、签到、评教等校园场景。 + tagline: 面向北航学生的跨平台校园服务聚合客户端,一站式覆盖课表、考试、成绩、图书馆座位、签到、评教等校园场景。 actions: - theme: brand text: 立刻下载 @@ -20,9 +20,9 @@ features: - title: 多端统一 details: Android、iOS、Desktop、Web 共用 Kotlin Multiplatform 契约与 Compose Multiplatform UI,一套代码覆盖全部平台。 - title: 校园服务聚合 - details: 覆盖认证、课表、考试、成绩、博雅、空教室、SPOC、希冀、签到、研讨室、阳光打卡和评教,无需在多个系统间切换。 + details: 覆盖认证、课表、考试、成绩、博雅、空教室、SPOC、希冀、图书馆座位、签到、研讨室、阳光打卡和评教,无需在多个系统间切换。 - title: 三种连接模式 - details: 客户端可在直连、WebVPN、服务器中转之间自由选择,按网络环境和平台能力自动适配。 + details: Android、iOS 和 Desktop 可选择直连、WebVPN、服务器中转;Web/Wasm 端按平台能力固定使用服务器中转。 - title: 全栈同仓 details: shared 管理契约与本地实现,server 适配上游系统并维护会话,composeApp 提供跨平台体验。 - title: 持续更新 diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 38e6c93b..cd61cacf 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -1,14 +1,15 @@ # 架构总览 -UBAA 是 Kotlin Multiplatform 全栈同仓项目。核心代码分为 `shared`、`composeApp`、`server`、`androidApp` 和 `iosApp`,其中 `shared` 是前后端契约和跨平台业务接入层的中心。 +UBAA 是 Kotlin Multiplatform 全栈同仓项目。核心代码分为 `shared`、`composeApp`、`server`、`androidApp` 和 `iosApp`,其中 `shared` 是前后端契约和跨平台业务接入层的中心;`buildSrc` 保存发布辅助 Gradle 任务。 ## 分层 - `composeApp`:Compose Multiplatform UI,覆盖 Desktop、Android、iOS、JS/Wasm Web 的主要界面和 ViewModel。 - `shared`:DTO、API 契约、连接模式、本地直连/WebVPN 实现、Token 和本地存储。 - `server`:Ktor 网关,负责服务端中转、JWT、Redis 会话、上游适配、指标、公告和版本检查。 -- `androidApp`:Android 壳工程,承载 Compose Activity 和 Android 打包配置。 +- `androidApp`:Android 壳工程,承载 Compose Activity、Android 打包配置和签名配置。 - `iosApp`:iOS 壳工程,承载 SwiftUI 入口和共享 framework。 +- `buildSrc`:自定义 Gradle 任务,例如 bhpan 只读验证和 release 资产上传。 ## 请求流 @@ -29,6 +30,7 @@ server 启动后安装 ContentNegotiation、CORS、JWT、CallId、CallLogging、 - `composeApp/build.gradle.kts` - `shared/build.gradle.kts` - `server/build.gradle.kts` +- `buildSrc/src/main/kotlin/cn/edu/ubaa/gradle/UploadLatestReleaseToBhpanTask.kt` - `server/src/main/kotlin/cn/edu/ubaa/Application.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiFactory.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/ConnectionRuntime.kt` diff --git a/docs/tech/configuration.md b/docs/tech/configuration.md index c124b337..30fa4c6e 100644 --- a/docs/tech/configuration.md +++ b/docs/tech/configuration.md @@ -11,7 +11,7 @@ ## 构建配置 - `project.version` 和 `project.version.code` 来自 `gradle.properties`。 -- `shared/build.gradle.kts` 用 BuildKonfig 写入 `APP_VERSION`、`VERSION_CODE` 和 `API_ENDPOINT`。 +- `shared/build.gradle.kts` 用 BuildKonfig 写入 `VERSION` 和 `API_ENDPOINT`。 - Android、Desktop、Web、Server 构建任务都在 Gradle 子模块中定义。 - docs 使用 `package.json` 和 `package-lock.json` 固定 VitePress 构建依赖。 @@ -21,6 +21,8 @@ - `REDIS_URI`:Redis 地址。 - `CORS_ALLOWED_ORIGINS`:允许的跨域来源。 - `REDIS_HEALTH_TIMEOUT_MS`:Redis readiness 超时时间。 +- `UPDATE_DOWNLOAD_URL`:版本检查返回的下载页面;未配置时使用 GitHub Releases。 +- `UBAA_SERVER_VERSION`:显式指定服务端版本;未配置时依次回退到系统属性、manifest 或 `gradle.properties`。 - JWT、分布式锁、预登录和刷新 Token 的预算配置集中在 `AuthConfig`。 ## GitHub Secrets @@ -35,6 +37,8 @@ 应用发布还依赖现有的 `API_ENDPOINT`、签名和 Cloudflare 相关 Secrets。 +当前仓库的 GitHub Actions workflow 为 `docs.yml`、`format.yml`、`release.yml`、`test.yml`;不存在独立的 `.github/workflows/upload.yml`。 + ## 来源文件 - `.gitignore` @@ -44,6 +48,7 @@ - `server/src/main/kotlin/cn/edu/ubaa/ServerRuntimeConfig.kt` - `server/src/main/kotlin/cn/edu/ubaa/auth/config/AuthConfig.kt` - `.github/workflows/release.yml` -- `.github/workflows/upload.yml` +- `.github/workflows/format.yml` +- `.github/workflows/test.yml` - `.github/workflows/docs.yml` - `package.json` diff --git a/docs/tech/connection-modes.md b/docs/tech/connection-modes.md index 9f3ecd7e..04d74e93 100644 --- a/docs/tech/connection-modes.md +++ b/docs/tech/connection-modes.md @@ -10,7 +10,10 @@ UBAA 支持三种连接模式:直连、WebVPN 和服务器中转。模式选 ## 平台能力 -`ConnectionRuntime.availableModes()` 根据 `supportsLocalConnectionModes()` 决定可选模式。支持本地连接的端提供三种模式;不支持的端只提供服务器中转,并自动保存该模式。 +`ConnectionRuntime.availableModes()` 根据 `supportsLocalConnectionModes()` 决定可选模式。当前平台实现如下: + +- Android、iOS、JVM/Desktop:`supportsLocalConnectionModes()` 返回 `true`,提供三种模式。 +- JS Browser、Wasm JS Browser:`supportsLocalConnectionModes()` 返回 `false`,只提供服务器中转,并自动保存该模式。 ## 切换语义 @@ -25,6 +28,10 @@ UBAA 支持三种连接模式:直连、WebVPN 和服务器中转。模式选 本地 WebVPN 访问通过 `LocalWebVpnSupport` 和上游 URL 包装完成。服务端中转使用 `VpnCipher` 处理需要走 VPN 的服务端请求。 +## CGYY 例外 + +普通本地上游请求通过 `localUpstreamUrl()` 决定是否包装 WebVPN URL。CGYY 另有 `localCgyyUpstreamUrl()`,当前始终返回原 URL;代码注释说明 `cgyy.buaa.edu.cn` 可公开访问,因此本地 CGYY 不会因为当前模式是 `WEBVPN` 而走 WebVPN 包装。 + ## 来源文件 - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/ConnectionRuntime.kt` diff --git a/docs/tech/modules.md b/docs/tech/modules.md index 4b813429..68556d89 100644 --- a/docs/tech/modules.md +++ b/docs/tech/modules.md @@ -2,7 +2,7 @@ ## shared -`shared` 负责跨平台契约和可复用业务逻辑。`model/dto` 定义网络与 UI 共用数据结构;`api/feature` 定义业务 backend 接口和 relay backend;`api/local` 定义直连/WebVPN 实现;`api/storage` 管理跨平台本地设置。 +`shared` 负责跨平台契约和可复用业务逻辑。`model/dto` 定义网络与 UI 共用数据结构;`api/feature` 定义业务 backend 接口和 relay backend;`api/local` 定义直连/WebVPN 实现;`api/storage` 管理跨平台本地设置。当前启用 Android、iOS Arm64/iOS Simulator Arm64、JVM、JS Browser 和 Wasm JS Browser source set。 ## composeApp @@ -14,11 +14,17 @@ ## 平台壳工程 -`androidApp` 和 `iosApp` 负责原生平台入口、图标、签名/配置和平台项目结构。业务逻辑不应散落到壳工程中。 +`androidApp` 和 `iosApp` 负责原生平台入口、图标、签名/配置和平台项目结构。业务逻辑不应散落到壳工程中。当前仓库不存在 `harmonyApp/` 顶层目录。 + +## buildSrc + +`buildSrc` 保存仓库自定义 Gradle 任务。当前根构建脚本注册 `verifyBhpanReadOnly` 和 `uploadLatestReleaseToBhpan`;后者会真实删除并重新上传 bhpan 中的 `UBAA-*` 资产,不能作为普通文档验证命令运行。 ## 来源文件 - `settings.gradle.kts` +- `build.gradle.kts` +- `buildSrc/src/main/kotlin/cn/edu/ubaa/gradle/UploadLatestReleaseToBhpanTask.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiFactory.kt` - `composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt` - `composeApp/src/webMain/kotlin/cn/edu/ubaa/main.kt` diff --git a/docs/tech/release-deployment.md b/docs/tech/release-deployment.md index edea39f2..11511a13 100644 --- a/docs/tech/release-deployment.md +++ b/docs/tech/release-deployment.md @@ -1,6 +1,6 @@ # 发布与部署 -仓库有三类发布:应用 release 构建、Web Wasm 上传、docs 静态站发布。docs 站点由 `dev` 分支 push 触发,构建后通过 SSH rsync 同步到服务器静态目录。 +仓库有三类发布:应用 release 构建、Web Wasm 发布、docs 静态站发布。docs 站点由 `dev` 分支 push 触发,构建后通过 SSH rsync 同步到服务器静态目录。 ## 应用 Release @@ -14,9 +14,14 @@ - iOS framework。 - Windows exe。 -## Web Wasm 上传 +## Web Wasm 发布 -`.github/workflows/upload.yml` 构建 `:composeApp:wasmJsBrowserDistribution`,并把 `composeApp/build/dist/wasmJs/productionExecutable` 部署到 Cloudflare Pages。release 事件 checkout 可能处于 detached HEAD,因此 deploy 命令显式传入 `--branch=main`。 +当前没有独立的 `.github/workflows/upload.yml`。Web Wasm 发布在 `.github/workflows/release.yml` 的 `build-web-wasm` job 中完成: + +1. 使用 `:composeApp:wasmJsBrowserDistribution` 构建 `composeApp/build/dist/wasmJs/productionExecutable`。 +2. 将 Web Wasm 产物压缩为 `UBAA-Web-Wasm-v{VERSION}.zip` 并上传到 GitHub Release。 +3. 使用 Cloudflare Wrangler 将同一目录部署到 Pages 项目 `ubaa`,命令显式传入 `--branch=main`。 +4. 调用 Cloudflare API 刷新 `app.buaa.team` 缓存。 ## Docs 发布 @@ -30,14 +35,25 @@ 6. 写入 SSH key 并执行 `ssh-keyscan`。 7. 在服务器创建目标目录。 8. `rsync -az --delete docs/.vitepress/dist/` 到 `DOCS_DEPLOY_PATH`。 +9. 调用 Cloudflare API 刷新 `www.buaa.team` 缓存。 服务器需要自行配置 Nginx、Caddy 或其他静态文件服务。本仓库只负责同步构建产物。 +## bhpan 发布资产任务 + +根 Gradle 构建脚本注册了两个 bhpan 相关任务: + +- `verifyBhpanReadOnly`:读取 `local.properties` 中的 bhpan 配置,做只读认证链验证。 +- `uploadLatestReleaseToBhpan`:读取 GitHub 最新 release,选择 `UBAA-*` 资产,删除 bhpan 中已有的同名前缀文件并重新上传。 + +`uploadLatestReleaseToBhpan` 会改变真实网盘文件,不属于普通构建验证命令;只有在明确授权真实发布/上传时才运行。 + ## 来源文件 - `.github/workflows/release.yml` -- `.github/workflows/upload.yml` - `.github/workflows/docs.yml` +- `build.gradle.kts` +- `buildSrc/src/main/kotlin/cn/edu/ubaa/gradle/UploadLatestReleaseToBhpanTask.kt` - `composeApp/src/webMain/resources/index.html` - `composeApp/src/webMain/resources/sw.js` - `package.json` diff --git a/docs/tech/server-routes.md b/docs/tech/server-routes.md index ef890315..183dbc95 100644 --- a/docs/tech/server-routes.md +++ b/docs/tech/server-routes.md @@ -25,8 +25,19 @@ server 使用 Ktor 注册公开路由、认证路由和业务路由。除健康 - `/api/v1/evaluation/*` - `/api/v1/spoc/*` - `/api/v1/judge/*` +- `/api/v1/libbook/*` - `/api/v1/ygdk/*` +图书馆座位当前注册的具体路由为: + +- `GET /api/v1/libbook/libraries` +- `GET /api/v1/libbook/areas` +- `GET /api/v1/libbook/areas/{areaId}` +- `GET /api/v1/libbook/areas/{areaId}/seats` +- `POST /api/v1/libbook/bookings` +- `GET /api/v1/libbook/reservations` +- `POST /api/v1/libbook/bookings/{bookingId}/cancel` + ## 维护规则 - 新路由要有 server route test。 @@ -41,6 +52,7 @@ server 使用 Ktor 注册公开路由、认证路由和业务路由。除健康 - `server/src/main/kotlin/cn/edu/ubaa/version/AppVersionRoutes.kt` - `server/src/main/kotlin/cn/edu/ubaa/announcement/AnnouncementRoutes.kt` - `server/src/main/kotlin/cn/edu/ubaa/judge/JudgeRoutes.kt` +- `server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookRoutes.kt` - `server/src/main/kotlin/cn/edu/ubaa/cgyy/CgyyRoutes.kt` - `server/src/test/kotlin/cn/edu/ubaa/ApplicationTest.kt` - `server/src/test/kotlin/cn/edu/ubaa/health/HealthSupportTest.kt` diff --git a/docs/tech/shared-api.md b/docs/tech/shared-api.md index e694b9cc..838de47b 100644 --- a/docs/tech/shared-api.md +++ b/docs/tech/shared-api.md @@ -14,13 +14,15 @@ shared API 是客户端和服务端之间的稳定层。UI 不直接拼接服务 - 认证与用户:`AuthApi.kt`、`AnnouncementService.kt`、`UpdateService.kt`。 - 课程与学习:`ScheduleApi.kt`、`GradeApi.kt`、`SpocApi.kt`、`JudgeApi.kt`。 -- 校园服务:`BykcApi.kt`、`ClassroomApi.kt`、`SigninApi.kt`、`CgyyApi.kt`、`YgdkApi.kt`、`EvaluationService.kt`。 +- 校园服务:`BykcApi.kt`、`ClassroomApi.kt`、`SigninApi.kt`、`CgyyApi.kt`、`LibBookApi.kt`、`YgdkApi.kt`、`EvaluationService.kt`。 - 存储:Token、凭据、公告已读、博雅筛选、研讨室表单。 ## 兼容性 服务端返回字段要兼容已发布客户端。尤其是版本检查、错误响应、成绩和作业 DTO,服务端能兼容时优先在服务端做向后兼容。 +版本检查响应当前同时返回 `latestVersion`、`status`、`updateAvailable`、`downloadUrl`、`releaseNotes`,并保留旧客户端兼容字段 `serverVersion` 和 `aligned`。 + ## 来源文件 - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/core/ApiClient.kt` @@ -29,6 +31,8 @@ shared API 是客户端和服务端之间的稳定层。UI 不直接拼接服务 - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/auth/UpdateService.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/GradeApi.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/JudgeApi.kt` +- `shared/src/commonMain/kotlin/cn/edu/ubaa/api/feature/LibBookApi.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Auth.kt` - `shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/Judge.kt` +- `shared/src/commonMain/kotlin/cn/edu/ubaa/model/dto/LibBook.kt` - `shared/src/commonTest/kotlin/cn/edu/ubaa/api/ApiFactoryDispatchTest.kt` diff --git a/docs/tech/state-storage.md b/docs/tech/state-storage.md index 6dd75837..d4bb7673 100644 --- a/docs/tech/state-storage.md +++ b/docs/tech/state-storage.md @@ -18,7 +18,7 @@ UBAA 的状态分为客户端本地状态、服务端会话状态和短期业务 ## 业务缓存 -服务端会缓存 signin、bykc、cgyy、spoc、judge、ygdk 等上游客户端上下文,并通过应用停止回调和定时清理释放。希冀本地实现还维护课程和详情缓存,避免重复解析上游页面。 +服务端会缓存 signin、bykc、cgyy、spoc、judge、libbook、ygdk 等上游客户端上下文,并通过应用停止回调和定时清理释放。希冀本地实现还维护课程和详情缓存,避免重复解析上游页面。 ## 来源文件 @@ -32,4 +32,5 @@ UBAA 的状态分为客户端本地状态、服务端会话状态和短期业务 - `server/src/main/kotlin/cn/edu/ubaa/auth/session/SessionManager.kt` - `server/src/main/kotlin/cn/edu/ubaa/auth/session/RedisSessionStore.kt` - `server/src/main/kotlin/cn/edu/ubaa/auth/session/RefreshTokenStore.kt` +- `server/src/main/kotlin/cn/edu/ubaa/libbook/LibBookService.kt` - `server/src/main/kotlin/cn/edu/ubaa/metrics/LoginMetrics.kt` diff --git a/docs/tech/testing.md b/docs/tech/testing.md index 95eea8e7..1f0ca923 100644 --- a/docs/tech/testing.md +++ b/docs/tech/testing.md @@ -28,7 +28,9 @@ npm run docs:build ## 文档验证 -docs 改动必须运行 `npm ci` 和 `npm run docs:build`。构建失败、死链或未提交 lockfile 都应阻止发布。 +CI 的 docs workflow 会运行 `npm ci` 和 `npm run docs:build`。本地如果只改 Markdown 且依赖已安装,可直接运行 `npm run docs:build`;修改 `package.json` 或 `package-lock.json` 时应先运行 `npm ci`。构建失败、死链或未提交 lockfile 都应阻止发布。文档涉及 Gradle 任务、共享契约或服务端路由说明时,再按影响面追加运行 `:shared:jvmTest`、`:server:test`、`:composeApp:jvmTest` 或 `spotlessCheck`。 + +`verifyBhpanReadOnly` 只用于明确允许的只读 bhpan 认证探测;`uploadLatestReleaseToBhpan` 会真实删除并重新上传发布资产,不能作为常规验证命令。 ## 来源文件 diff --git a/gradle.properties b/gradle.properties index 15d6052a..323d694e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,5 +11,5 @@ org.gradle.caching=true android.nonTransitiveRClass=true android.useAndroidX=true # Project Versioning -project.version=1.7.5 +project.version=1.7.6 project.version.code=28 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 13051b52..30b27ea7 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -193,7 +193,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.3; + MARKETING_VERSION = 1.7.6; PRODUCT_BUNDLE_IDENTIFIER = cn.edu.ubaa; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = UBAA; SWIFT_EMIT_LOC_STRINGS = YES; @@ -226,7 +226,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.3; + MARKETING_VERSION = 1.7.6; PRODUCT_BUNDLE_IDENTIFIER = cn.edu.ubaa; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationClient.kt b/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationClient.kt index dd085b52..6659ff7a 100644 --- a/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationClient.kt +++ b/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationClient.kt @@ -127,27 +127,6 @@ class EvaluationClient( } } - /** - * 获取当前学期代码(如 "2025-20261")。 - * - * @return 学期代码字符串,若获取失败返回 null。 - */ - suspend fun fetchCurrentXnxq(): String? { - return try { - val resp: SpocResult> = - AppObservability.observeUpstreamRequest("evaluation", "fetch_current_xnxq") { - getClient().post("$baseUrl/component/queryXnxq").body() - } - val first = resp.content?.firstOrNull() ?: return null - val xn = first["xn"]?.toString()?.replace("\"", "") ?: "" - val xq = first["xq"]?.toString()?.replace("\"", "") ?: "" - "$xn$xq" - } catch (e: Exception) { - log.error("Failed to fetch xnxq", e) - null - } - } - /** * 获取用户的评教任务列表。 * @@ -160,8 +139,6 @@ class EvaluationClient( getClient() .get("$baseUrl/personnelEvaluation/listObtainPersonnelEvaluationTasks") { parameter("yhdm", username) - parameter("rwmc", "") - parameter("sfyp", "0") parameter("pageNum", "1") parameter("pageSize", "10") } @@ -187,9 +164,6 @@ class EvaluationClient( getClient() .get("$baseUrl/evaluationMethodSix/getQuestionnaireListToTask") { parameter("rwid", rwid) - parameter("sfyp", "0") - parameter("pageNum", "1") - parameter("pageSize", "999") } .body() } @@ -201,40 +175,30 @@ class EvaluationClient( } /** - * 获取指定任务、问卷和学期下的**未评教**课程列表。 - * - * 注意:参数 sfyp 控制过滤条件 - * - sfyp=0:只返回未评教的课程 - * - sfyp=1:只返回已评教的课程 - * - 调用前会自动切换问卷模式(reviseQuestionnairePattern) + * 获取指定问卷下的当前待评课程列表。 * - * @param rwid 任务 ID。 * @param wjid 问卷 ID。 - * @param xnxq 学期代码。 - * @param msid 模式 ID,可选。 - * @param sfyp 是否已评教,0=未评教,1=已评教。 - * @return 课程列表(根据 sfyp 参数过滤)。 + * @return 课程列表。默认不传学期和已评过滤参数,由上游返回最新待评列表。 */ suspend fun fetchCourses( rwid: String, wjid: String, - xnxq: String, - msid: String = "1", - sfyp: String = "0", + xnxq: String? = null, + msid: String? = null, + sfyp: String? = null, ): List { return try { - // 切换模式 (Revise Questionnaire Pattern) - reviseQuestionnairePattern(rwid, wjid, msid) + if (msid != null) { + reviseQuestionnairePattern(rwid, wjid, msid) + } val resp: SpocResult> = AppObservability.observeUpstreamRequest("evaluation", "fetch_courses") { getClient() .get("$baseUrl/evaluationMethodSix/getRequiredReviewsData") { - parameter("sfyp", sfyp) + if (sfyp != null) parameter("sfyp", sfyp) parameter("wjid", wjid) - parameter("xnxq", xnxq) - parameter("pageNum", "1") - parameter("pageSize", "999") + if (xnxq != null) parameter("xnxq", xnxq) } .body() } diff --git a/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationService.kt b/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationService.kt index 6f4638f2..73357716 100644 --- a/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationService.kt +++ b/server/src/main/kotlin/cn/edu/ubaa/evaluation/EvaluationService.kt @@ -19,10 +19,9 @@ class EvaluationService { * * 执行流程: * 1. 初始化评教系统会话(CAS认证) - * 2. 获取当前学期代码 - * 3. 获取所有评教任务 - * 4. 对每个任务,获取问卷列表 - * 5. 对每个问卷,分别获取未评教课程(sfyp=0)和已评教课程(sfyp=1) + * 2. 获取所有评教任务 + * 3. 对每个任务,获取问卷列表 + * 4. 对每个问卷,按上游默认条件获取最新待评课程 * 6. 返回汇总后的课程列表和进度信息 * * @param username 用户学号。 @@ -39,15 +38,6 @@ class EvaluationService { ) } - val xnxq = client.fetchCurrentXnxq() - if (xnxq == null) { - log.warn("Failed to fetch xnxq for user: $username") - return@withUpstreamDeadline EvaluationCoursesResponse( - courses = emptyList(), - progress = EvaluationProgress(0, 0, 0), - ) - } - val tasks = client.fetchTasks() // 使用 map 以课程唯一键去重,若任一来源标记已评教,则合并结果也标记已评教 val courseMap = mutableMapOf() @@ -57,13 +47,8 @@ class EvaluationService { for (wj in questionnaires) { val msid = wj.msid ?: "1" - // 获取未评教课程 (sfyp=0) - val pendingCourses = client.fetchCourses(task.rwid, wj.wjid, xnxq, msid, "0") - mergeCourses(courseMap, pendingCourses, task.rwid, wj.wjid, xnxq, msid, false) - - // 获取已评教课程 (sfyp=1) - val evaluatedCourses = client.fetchCourses(task.rwid, wj.wjid, xnxq, msid, "1") - mergeCourses(courseMap, evaluatedCourses, task.rwid, wj.wjid, xnxq, msid, true) + val pendingCourses = client.fetchCourses(task.rwid, wj.wjid) + mergeCourses(courseMap, pendingCourses, task.rwid, wj.wjid, null, msid, false) } } @@ -96,15 +81,10 @@ class EvaluationService { * ``` * - 若没有待评教任务,则直接返回空列表(用户已完成所有评教) * ``` - * 3. 获取当前学期代码 - * 4. 获取所有评教任务 - * 5. 对每个任务,获取问卷列表 - * 6. 对每个问卷,调用 getRequiredReviewsData(sfyp=0)获取**未评教**的课程 - * - * ``` - * - 参数 sfyp=0 表示"是否已评教=否",已经由服务器过滤 - * ``` - * 7. 返回汇总后的待评教课程列表 + * 3. 获取所有评教任务 + * 4. 对每个任务,获取问卷列表 + * 5. 对每个问卷,调用 getRequiredReviewsData 的默认查询获取最新待评课程 + * 6. 返回汇总后的待评教课程列表 * * **重要**:返回的课程列表已经是未评教的课程,前端可直接展示。 * @@ -195,36 +175,30 @@ class EvaluationService { allQuestions.forEachIndexed { i, q -> val tmlx = q["tmlx"]?.jsonPrimitive?.content ?: "1" + val isChoiceQuestion = tmlx == "1" val tmxxlist = q["tmxxlist"]?.jsonArray ?: emptyJsonArray - if (tmxxlist.isEmpty()) return@forEachIndexed - val firstOptionId = tmxxlist[0].jsonObject["tmxxid"] + val firstOptionId = tmxxlist.firstOrNull()?.jsonObject?.get("tmxxid") val selectedOptionId = - if (i == randomIndex && tmxxlist.size > 1) { + if (isChoiceQuestion && i == randomIndex && tmxxlist.size > 1) { tmxxlist[1].jsonObject["tmxxid"] - } else { + } else if (isChoiceQuestion) { firstOptionId + } else { + null } pjxxlist.add( buildJsonObject { put("sjly", "1") - put("stlx", tmlx) + put("stlx", if (isChoiceQuestion) "1" else "6") put("wjid", course.wjid) put("wjssrwid", pjjg["wjssrwid"] ?: JsonNull) - - // 填空题 (tmlx=6) 特殊处理:使用第一个选项 ID - // 其他题型使用空字符串 - if (tmlx == "6") { - if (firstOptionId != null) { - put("wjstctid", firstOptionId) - } else { - put("wjstctid", "") - } - } else { - put("wjstctid", "") - } - + put( + "wjstctid", + if (isChoiceQuestion) JsonPrimitive("") + else firstOptionId ?: JsonPrimitive(""), + ) put("wjstid", q["tmid"] ?: JsonNull) putJsonArray("xxdalist") { selectedOptionId?.let { add(it) } } @@ -292,27 +266,28 @@ class EvaluationService { courses: List, rwid: String, wjid: String, - xnxq: String, + xnxq: String?, msid: String, isEvaluated: Boolean, ) { courses.forEach { spocCourse -> val key = "${rwid}_${wjid}_${spocCourse.kcdm}_${spocCourse.bpdm}" val existing = courseMap[key] + val evaluatedByUpstream = (spocCourse.ypjcs ?: 0) > 0 val merged = EvaluationCourse( id = key, kcmc = spocCourse.kcmc, bpmc = spocCourse.bpmc ?: existing?.bpmc ?: "未知教师", - isEvaluated = isEvaluated || existing?.isEvaluated == true, + isEvaluated = isEvaluated || evaluatedByUpstream || existing?.isEvaluated == true, rwid = rwid, wjid = wjid, kcdm = spocCourse.kcdm, bpdm = spocCourse.bpdm, pjrdm = spocCourse.pjrdm ?: existing?.pjrdm, pjrmc = spocCourse.pjrmc ?: existing?.pjrmc, - xnxq = xnxq, + xnxq = spocCourse.xnxq ?: xnxq, msid = msid, zdmc = spocCourse.zdmc ?: existing?.zdmc ?: "STID", ypjcs = spocCourse.ypjcs ?: existing?.ypjcs ?: 0, diff --git a/server/src/main/kotlin/cn/edu/ubaa/signin/SigninClient.kt b/server/src/main/kotlin/cn/edu/ubaa/signin/SigninClient.kt index e3298630..dec2d1de 100644 --- a/server/src/main/kotlin/cn/edu/ubaa/signin/SigninClient.kt +++ b/server/src/main/kotlin/cn/edu/ubaa/signin/SigninClient.kt @@ -213,11 +213,7 @@ class SigninClient( val serverTimestamp = AppObservability.observeUpstreamRequest("iclass", "get_timestamp") { client - .get( - VpnCipher.toVpnUrl( - "http://iclass.buaa.edu.cn:8081/app/common/get_timestamp.action" - ) - ) + .get(signinCheckinUrl("app/common/get_timestamp.action")) .body() .get("timestamp") ?.jsonPrimitive @@ -227,10 +223,7 @@ class SigninClient( AppObservability.observeUpstreamRequest("iclass", "sign_in") { val response = client.submitForm( - url = - VpnCipher.toVpnUrl( - "http://iclass.buaa.edu.cn:8081/app/course/stu_scan_sign.action" - ), + url = signinCheckinUrl("eschool/app/course/stu_scan_sign.action"), formParameters = Parameters.build { append("id", userId!!) }, ) { header("sessionId", sessionId) @@ -271,6 +264,16 @@ class SigninClient( } } + private fun signinCheckinUrl(path: String): String { + val base = + if (VpnCipher.isEnabled) { + "https://iclass.buaa.edu.cn:8347" + } else { + "http://iclass.buaa.edu.cn:8081" + } + return VpnCipher.toVpnUrl("$base/$path") + } + fun close() { client.close() userId = null diff --git a/server/src/test/kotlin/cn/edu/ubaa/signin/SigninClientTest.kt b/server/src/test/kotlin/cn/edu/ubaa/signin/SigninClientTest.kt index abdcbcea..7f27f7db 100644 --- a/server/src/test/kotlin/cn/edu/ubaa/signin/SigninClientTest.kt +++ b/server/src/test/kotlin/cn/edu/ubaa/signin/SigninClientTest.kt @@ -8,15 +8,19 @@ import cn.edu.ubaa.utils.VpnCipher import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.cookies.CookiesStorage import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.ByteReadChannel import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json class SigninClientTest { @@ -153,4 +157,110 @@ class SigninClientTest { VpnCipher.isEnabled = originalVpnEnabled } } + + @Test + fun `sign in submits to eschool endpoint after resolving loginName`() = runBlocking { + val originalVpnEnabled = VpnCipher.isEnabled + VpnCipher.isEnabled = false + val loginName = "Rjc1QkJDMUMxNzVENkY0NkZCNzFDMEM5RjYwNzg4RDg=" + val sessionManager = + SessionManager( + sessionStore = InMemorySessionStore(), + cookieStorageFactory = InMemoryCookieStorageFactory(), + clientFactory = { _: CookiesStorage -> + HttpClient(MockEngine) { + engine { + addHandler { request -> + when { + request.url.host == "iclass.buaa.edu.cn" && + request.url.port == 8346 && + request.url.parameters["type"] == "jumpMyCenter" -> + respond( + content = "", + status = HttpStatusCode.Found, + headers = + headersOf( + HttpHeaders.Location, + "https://iclass.buaa.edu.cn:8346/?loginName=$loginName&type=jumpMyCenter#/MyCenter", + ), + ) + else -> + error("Unexpected SSO request: ${request.method.value} ${request.url}") + } + } + } + } + }, + ) + val appClient = + HttpClient(MockEngine) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + engine { + addHandler { request -> + when { + request.url.host == "iclass.buaa.edu.cn" && + request.url.port == 8347 && + request.url.encodedPath == "/app/user/login.action" -> { + assertEquals(loginName, request.url.parameters["phone"]) + respond( + content = + ByteReadChannel( + """{"STATUS":0,"result":{"id":"user-1","sessionId":"session-1"}}""" + ), + status = HttpStatusCode.OK, + headers = + headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), + ), + ) + } + request.url.toString() == + "http://iclass.buaa.edu.cn:8081/app/common/get_timestamp.action" -> + respond( + content = ByteReadChannel("""{"timestamp":"1713600000"}"""), + status = HttpStatusCode.OK, + headers = + headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), + ), + ) + request.url.toString() == + "http://iclass.buaa.edu.cn:8081/eschool/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> { + assertEquals("session-1", request.headers["sessionId"]) + respond( + content = + ByteReadChannel( + """{"STATUS":0,"ERRMSG":"签到成功","result":{"stuSignStatus":1}}""" + ), + status = HttpStatusCode.OK, + headers = + headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), + ), + ) + } + else -> error("Unexpected app request: ${request.method.value} ${request.url}") + } + } + } + } + + try { + val candidate = sessionManager.prepareSession("24182104") + sessionManager.commitSession(candidate, UserData("Test User", "24182104")) + + val client = SigninClient("24182104", sessionManager = sessionManager, client = appClient) + val result = client.signIn("course-1") + + assertTrue(result.first, result.second) + assertEquals("签到成功", result.second) + } finally { + appClient.close() + sessionManager.close() + VpnCipher.isEnabled = originalVpnEnabled + } + } } diff --git a/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalEvaluationService.kt b/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalEvaluationService.kt index 8fd1e22b..8a8001a3 100644 --- a/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalEvaluationService.kt +++ b/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalEvaluationService.kt @@ -55,7 +55,6 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { return@runLocalEvaluationCall emptyResponse } - val xnxq = fetchCurrentXnxq() ?: return@runLocalEvaluationCall emptyResponse val tasks = fetchTasks() val courseMap = linkedMapOf() @@ -64,22 +63,13 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { val msid = questionnaire.msid?.takeIf { it.isNotBlank() } ?: "1" mergeCourses( courseMap = courseMap, - courses = fetchCourses(task.rwid, questionnaire.wjid, xnxq, msid, sfyp = "0"), + courses = fetchCourses(task.rwid, questionnaire.wjid), rwid = task.rwid, wjid = questionnaire.wjid, - xnxq = xnxq, + xnxq = null, msid = msid, isEvaluated = false, ) - mergeCourses( - courseMap = courseMap, - courses = fetchCourses(task.rwid, questionnaire.wjid, xnxq, msid, sfyp = "1"), - rwid = task.rwid, - wjid = questionnaire.wjid, - xnxq = xnxq, - msid = msid, - isEvaluated = true, - ) } } @@ -191,23 +181,6 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { } } - private suspend fun fetchCurrentXnxq(): String? { - return try { - val response = - LocalUpstreamClientProvider.shared() - .post(localUpstreamUrl("https://spoc.buaa.edu.cn/pjxt/component/queryXnxq")) - val envelope = decodeEnvelope>(response) - val first = envelope.content?.firstOrNull() ?: return null - val xn = first.string("xn").orEmpty() - val xq = first.string("xq").orEmpty() - if (xn.isBlank() || xq.isBlank()) null else "$xn$xq" - } catch (e: LocalEvaluationAuthenticationException) { - throw e - } catch (_: Exception) { - null - } - } - private suspend fun fetchTasks(): List { return try { val authSession = @@ -219,8 +192,6 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { ) ) { parameter("yhdm", authSession.user.schoolid.ifBlank { authSession.username }) - parameter("rwmc", "") - parameter("sfyp", "0") parameter("pageNum", "1") parameter("pageSize", "10") } @@ -241,9 +212,6 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { ) ) { parameter("rwid", rwid) - parameter("sfyp", "0") - parameter("pageNum", "1") - parameter("pageSize", "999") } decodeEnvelope>(response).result.orEmpty() } catch (e: LocalEvaluationAuthenticationException) { @@ -256,23 +224,15 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { private suspend fun fetchCourses( rwid: String, wjid: String, - xnxq: String, - msid: String, - sfyp: String, ): List { return try { - reviseQuestionnairePattern(rwid, wjid, msid) val response = LocalUpstreamClientProvider.shared().get( localUpstreamUrl( "https://spoc.buaa.edu.cn/pjxt/evaluationMethodSix/getRequiredReviewsData" ) ) { - parameter("sfyp", sfyp) parameter("wjid", wjid) - parameter("xnxq", xnxq) - parameter("pageNum", "1") - parameter("pageSize", "999") } decodeEnvelope>(response).result.orEmpty() } catch (e: LocalEvaluationAuthenticationException) { @@ -437,25 +397,27 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { useSecondOption: Boolean, ): JsonObject { val questionType = question.string("tmlx") ?: "1" + val isChoiceQuestion = questionType == "1" val options = question["tmxxlist"]?.jsonArray ?: JsonArray(emptyList()) val firstOptionId = options.firstOrNull()?.jsonObjectOrNull()?.get("tmxxid") val selectedOptionId = - if (useSecondOption && options.size > 1) { + if (isChoiceQuestion && useSecondOption && options.size > 1) { options.getOrNull(1)?.jsonObjectOrNull()?.get("tmxxid") - } else { + } else if (isChoiceQuestion) { firstOptionId + } else { + null } return buildJsonObject { put("sjly", "1") - put("stlx", questionType) + put("stlx", if (isChoiceQuestion) "1" else "6") put("wjid", course.wjid) put("wjssrwid", payload["wjssrwid"] ?: JsonNull) - if (questionType == "6") { - put("wjstctid", firstOptionId ?: JsonPrimitive("")) - } else { - put("wjstctid", "") - } + put( + "wjstctid", + if (isChoiceQuestion) JsonPrimitive("") else firstOptionId ?: JsonPrimitive(""), + ) put("wjstid", question["tmid"] ?: JsonNull) putJsonArray("xxdalist") { if (selectedOptionId != null) add(selectedOptionId) } } @@ -518,7 +480,7 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { courses: List, rwid: String, wjid: String, - xnxq: String, + xnxq: String?, msid: String, isEvaluated: Boolean, ) { @@ -527,19 +489,20 @@ internal class LocalEvaluationServiceBackend : EvaluationServiceBackend { val bpdm = course.string("bpdm") val key = "${rwid}_${wjid}_${kcdm}_${bpdm.orEmpty()}" val existing = courseMap[key] + val evaluatedByUpstream = (course.int("ypjcs") ?: 0) > 0 courseMap[key] = EvaluationCourse( id = key, kcmc = course.string("kcmc") ?: existing?.kcmc ?: "未知课程", bpmc = course.string("bpmc") ?: existing?.bpmc ?: "未知教师", - isEvaluated = isEvaluated || existing?.isEvaluated == true, + isEvaluated = isEvaluated || evaluatedByUpstream || existing?.isEvaluated == true, rwid = rwid, wjid = wjid, kcdm = kcdm, bpdm = bpdm ?: existing?.bpdm, pjrdm = course.string("pjrdm") ?: existing?.pjrdm, pjrmc = course.string("pjrmc") ?: existing?.pjrmc, - xnxq = xnxq, + xnxq = course.string("xnxq") ?: xnxq, msid = msid, zdmc = course.string("zdmc") ?: existing?.zdmc ?: "STID", ypjcs = course.int("ypjcs") ?: existing?.ypjcs ?: 0, diff --git a/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSigninApi.kt b/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSigninApi.kt index f927a668..9f585aa7 100644 --- a/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSigninApi.kt +++ b/shared/src/commonMain/kotlin/cn/edu/ubaa/api/local/LocalSigninApi.kt @@ -1,5 +1,7 @@ package cn.edu.ubaa.api.local +import cn.edu.ubaa.api.ConnectionMode +import cn.edu.ubaa.api.ConnectionRuntime import cn.edu.ubaa.api.SIGNIN_LOGIN_REDIRECT_LIMIT import cn.edu.ubaa.api.SIGNIN_MY_CENTER_URL import cn.edu.ubaa.api.extractSigninLoginNameFromUrl @@ -124,9 +126,7 @@ internal class LocalSigninApiBackend : SigninApiBackend { return try { val timestamp = LocalUpstreamClientProvider.shared() - .get( - localUpstreamUrl("http://iclass.buaa.edu.cn:8081/app/common/get_timestamp.action") - ) + .get(localSigninCheckinUrl("app/common/get_timestamp.action")) .bodyAsText() .let(json::parseToJsonElement) .jsonObject["timestamp"] @@ -140,10 +140,7 @@ internal class LocalSigninApiBackend : SigninApiBackend { val response = LocalUpstreamClientProvider.shared().submitForm( - url = - localUpstreamUrl( - "http://iclass.buaa.edu.cn:8081/app/course/stu_scan_sign.action" - ), + url = localSigninCheckinUrl("eschool/app/course/stu_scan_sign.action"), formParameters = Parameters.build { append("id", signinSession.userId) }, ) { header("sessionId", signinSession.sessionId) @@ -259,6 +256,15 @@ private data class LocalSigninSession( val sessionId: String, ) +private fun localSigninCheckinUrl(path: String): String { + val base = + when (ConnectionRuntime.currentMode()) { + ConnectionMode.WEBVPN -> "https://iclass.buaa.edu.cn:8347" + else -> "http://iclass.buaa.edu.cn:8081" + } + return localUpstreamUrl("$base/$path") +} + private fun mapSigninClass(element: JsonElement): SigninClassDto { val payload = element.jsonObject return SigninClassDto( diff --git a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyyApiBackendTest.kt b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyyApiBackendTest.kt index 8a058399..9889559f 100644 --- a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyyApiBackendTest.kt +++ b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalCgyyApiBackendTest.kt @@ -482,7 +482,7 @@ class LocalCgyyApiBackendTest { } @Test - fun `cgyy api uses webvpn wrapped urls when current mode is webvpn`() = runTest { + fun `cgyy api keeps direct urls when current mode is webvpn`() = runTest { ConnectionModeStore.save(ConnectionMode.WEBVPN) ConnectionRuntime.resolveSelectedMode() LocalAuthSessionStore.save( @@ -497,8 +497,8 @@ class LocalCgyyApiBackendTest { val engine = MockEngine { request -> requestedUrls += request.url.toString() when { - request.url.host == "d.buaa.edu.cn" && - request.url.encodedPath.endsWith("/sso/manageLogin") -> + request.url.host == "cgyy.buaa.edu.cn" && + request.url.encodedPath == "/venue-zhjs-server/sso/manageLogin" -> respond( content = ByteReadChannel.Empty, status = HttpStatusCode.OK, @@ -508,7 +508,8 @@ class LocalCgyyApiBackendTest { "sso_buaa_zhjs_token=sso-token; Path=/; HttpOnly", ), ) - request.url.host == "d.buaa.edu.cn" && request.url.encodedPath.endsWith("/api/login") -> + request.url.host == "cgyy.buaa.edu.cn" && + request.url.encodedPath == "/venue-zhjs-server/api/login" -> respondJson( """ { @@ -523,8 +524,8 @@ class LocalCgyyApiBackendTest { """ .trimIndent() ) - request.url.host == "d.buaa.edu.cn" && - request.url.encodedPath.endsWith("/api/front/website/venues") -> + request.url.host == "cgyy.buaa.edu.cn" && + request.url.encodedPath == "/venue-zhjs-server/api/front/website/venues" -> respondJson( """ { @@ -559,7 +560,7 @@ class LocalCgyyApiBackendTest { assertTrue(result.isSuccess, result.exceptionOrNull()?.message.orEmpty()) assertEquals(202, result.getOrNull()?.singleOrNull()?.id) - assertTrue(requestedUrls.all { it.startsWith("https://d.buaa.edu.cn/") }) + assertTrue(requestedUrls.all { it.startsWith("https://cgyy.buaa.edu.cn/") }) } @Test @@ -668,6 +669,13 @@ class LocalCgyyApiBackendTest { } } } + LocalUpstreamClientProvider.isolatedClientFactory = { followRedirects, cookieStorage -> + HttpClient(engine) { + this.followRedirects = followRedirects + install(ContentNegotiation) { json(this@LocalCgyyApiBackendTest.json) } + install(HttpCookies) { storage = cookieStorage } + } + } } } diff --git a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalEvaluationServiceBackendTest.kt b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalEvaluationServiceBackendTest.kt index aafede9e..c21a1de7 100644 --- a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalEvaluationServiceBackendTest.kt +++ b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalEvaluationServiceBackendTest.kt @@ -17,14 +17,22 @@ import io.ktor.client.plugins.cookies.HttpCookies import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import io.ktor.http.content.TextContent import io.ktor.http.headersOf import io.ktor.utils.io.ByteReadChannel import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive class LocalEvaluationServiceBackendTest { @BeforeTest @@ -68,26 +76,193 @@ class LocalEvaluationServiceBackendTest { status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "text/html"), ) - request.url.host == "spoc.buaa.edu.cn" && - request.url.encodedPath == "/pjxt/component/queryXnxq" -> - respondJson("""{"code":200,"content":[{"xn":"2025-2026","xq":"1"}]}""") request.url.host == "spoc.buaa.edu.cn" && request.url.encodedPath == "/pjxt/personnelEvaluation/listObtainPersonnelEvaluationTasks" -> { assertEquals("22373333", request.url.parameters["yhdm"]) + assertEquals(null, request.url.parameters["rwmc"]) + assertEquals(null, request.url.parameters["sfyp"]) respondJson("""{"code":200,"result":{"list":[{"rwid":"rw1","rwmc":"2026春评教"}]}}""") } request.url.host == "spoc.buaa.edu.cn" && request.url.encodedPath == "/pjxt/evaluationMethodSix/getQuestionnaireListToTask" -> { assertEquals("rw1", request.url.parameters["rwid"]) + assertEquals(null, request.url.parameters["sfyp"]) respondJson("""{"code":200,"result":[{"wjid":"wj1","wjmc":"教学评价","msid":"2"}]}""") } request.url.host == "spoc.buaa.edu.cn" && - request.url.encodedPath == "/pjxt/evaluationMethodSix/reviseQuestionnairePattern" -> - respondJson("""{"code":200}""") + request.url.encodedPath == "/pjxt/evaluationMethodSix/getRequiredReviewsData" -> { + assertEquals(null, request.url.parameters["sfyp"]) + assertEquals(null, request.url.parameters["xnxq"]) + respondJson( + """ + { + "code": 200, + "result": [ + { + "kcdm": "CS101", + "kcmc": "操作系统", + "bpmc": "李老师", + "bpdm": "T001", + "pjrdm": "22373333", + "pjrmc": "测试学生", + "zdmc": "STID", + "ypjcs": 0, + "xypjcs": 1, + "sxz": "1", + "rwh": "rwh-1", + "xn": "2025-2026", + "xq": "1", + "xnxq": "2025-20261", + "pjlxid": "2", + "sfksqbpj": "1", + "yxsfktjst": "0" + } + ] + } + """ + .trimIndent() + ) + } + else -> error("Unexpected url: ${request.url}") + } + } + useMockUpstream(engine) + + val result = LocalEvaluationServiceBackend().getAllEvaluations() + + assertTrue(result.isSuccess) + val response = result.getOrNull() + assertEquals(1, response?.courses?.size) + assertEquals(1, response?.progress?.totalCourses) + assertEquals(1, response?.progress?.pendingCourses) + assertEquals(0, response?.progress?.evaluatedCourses) + assertEquals(false, response?.courses?.firstOrNull()?.isEvaluated) + assertEquals("rw1_wj1_CS101_T001", response?.courses?.firstOrNull()?.id) + assertEquals("2025-20261", response?.courses?.firstOrNull()?.xnxq) + assertEquals("2", response?.courses?.firstOrNull()?.msid) + } + + @Test + fun `local evaluation service fetches latest pending courses with minimal upstream filters`() = + runTest { + val engine = MockEngine { request -> + when { + request.url.host == "spoc.buaa.edu.cn" && request.url.encodedPath == "/pjxt/cas" -> + respond( + content = ByteReadChannel.Empty, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/html"), + ) + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == + "/pjxt/personnelEvaluation/listObtainPersonnelEvaluationTasks" -> { + assertEquals("22373333", request.url.parameters["yhdm"]) + assertEquals("1", request.url.parameters["pageNum"]) + assertEquals("10", request.url.parameters["pageSize"]) + assertEquals(null, request.url.parameters["rwmc"]) + assertEquals(null, request.url.parameters["sfyp"]) + assertEquals(null, request.url.parameters["xnxq"]) + respondJson( + """ + { + "code": 200, + "result": { + "list": [ + {"rwid": "rw-latest", "rwmc": "最新评教任务"} + ] + } + } + """ + .trimIndent() + ) + } + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == + "/pjxt/evaluationMethodSix/getQuestionnaireListToTask" -> { + assertEquals("rw-latest", request.url.parameters["rwid"]) + assertEquals(null, request.url.parameters["sfyp"]) + assertEquals(null, request.url.parameters["pageNum"]) + assertEquals(null, request.url.parameters["pageSize"]) + respondJson( + """ + { + "code": 200, + "result": [ + {"wjid": "wj-latest", "wjmc": "课堂教学评价"} + ] + } + """ + .trimIndent() + ) + } + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == "/pjxt/evaluationMethodSix/getRequiredReviewsData" -> { + assertEquals("wj-latest", request.url.parameters["wjid"]) + assertEquals(null, request.url.parameters["sfyp"]) + assertEquals(null, request.url.parameters["xnxq"]) + assertEquals(null, request.url.parameters["pageNum"]) + assertEquals(null, request.url.parameters["pageSize"]) + respondJson( + """ + { + "code": 200, + "result": [ + { + "rwid": "rw-latest", + "wjid": "wj-latest", + "kcdm": "MATH101", + "kcmc": "高等数学", + "bpmc": "张老师", + "bpdm": "T009", + "pjrdm": "22373333", + "sxz": "1", + "rwh": "rwh-latest", + "ypjcs": 0 + } + ] + } + """ + .trimIndent() + ) + } + else -> error("Unexpected url: ${request.url}") + } + } + useMockUpstream(engine) + + val result = LocalEvaluationServiceBackend().getAllEvaluations() + + assertTrue(result.isSuccess) + val response = result.getOrThrow() + assertEquals(1, response.courses.size) + assertEquals(1, response.progress.totalCourses) + assertEquals(1, response.progress.pendingCourses) + assertEquals(0, response.progress.evaluatedCourses) + assertEquals("rw-latest_wj-latest_MATH101_T009", response.courses.single().id) + assertEquals("高等数学", response.courses.single().kcmc) + assertEquals(null, response.courses.single().xnxq) + } + + @Test + fun `local evaluation service marks courses evaluated from upstream ypjcs`() = runTest { + val engine = MockEngine { request -> + when { + request.url.host == "spoc.buaa.edu.cn" && request.url.encodedPath == "/pjxt/cas" -> + respond( + content = ByteReadChannel.Empty, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/html"), + ) request.url.host == "spoc.buaa.edu.cn" && - request.url.encodedPath == "/pjxt/evaluationMethodSix/getRequiredReviewsData" && - request.url.parameters["sfyp"] == "0" -> + request.url.encodedPath == + "/pjxt/personnelEvaluation/listObtainPersonnelEvaluationTasks" -> + respondJson("""{"code":200,"result":{"list":[{"rwid":"rw1","rwmc":"2026春评教"}]}}""") + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == "/pjxt/evaluationMethodSix/getQuestionnaireListToTask" -> + respondJson("""{"code":200,"result":[{"wjid":"wj1","wjmc":"教学评价"}]}""") + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == "/pjxt/evaluationMethodSix/getRequiredReviewsData" -> respondJson( """ { @@ -98,39 +273,14 @@ class LocalEvaluationServiceBackendTest { "kcmc": "操作系统", "bpmc": "李老师", "bpdm": "T001", - "pjrdm": "22373333", - "pjrmc": "测试学生", - "zdmc": "STID", - "ypjcs": 0, - "xypjcs": 1, - "sxz": "1", - "rwh": "rwh-1", - "xn": "2025-2026", - "xq": "1", - "pjlxid": "2", - "sfksqbpj": "1", - "yxsfktjst": "0" - } - ] - } - """ - .trimIndent() - ) - request.url.host == "spoc.buaa.edu.cn" && - request.url.encodedPath == "/pjxt/evaluationMethodSix/getRequiredReviewsData" && - request.url.parameters["sfyp"] == "1" -> - respondJson( - """ - { - "code": 200, - "result": [ + "ypjcs": 1 + }, { "kcdm": "CS102", "kcmc": "编译原理", "bpmc": "王老师", "bpdm": "T002", - "pjrdm": "22373333", - "pjrmc": "测试学生" + "ypjcs": 0 } ] } @@ -145,16 +295,14 @@ class LocalEvaluationServiceBackendTest { val result = LocalEvaluationServiceBackend().getAllEvaluations() assertTrue(result.isSuccess) - val response = result.getOrNull() - assertEquals(2, response?.courses?.size) - assertEquals(2, response?.progress?.totalCourses) - assertEquals(1, response?.progress?.pendingCourses) - assertEquals(1, response?.progress?.evaluatedCourses) - assertEquals(false, response?.courses?.firstOrNull()?.isEvaluated) - assertEquals(true, response?.courses?.lastOrNull()?.isEvaluated) - assertEquals("rw1_wj1_CS101_T001", response?.courses?.firstOrNull()?.id) - assertEquals("2025-20261", response?.courses?.firstOrNull()?.xnxq) - assertEquals("2", response?.courses?.firstOrNull()?.msid) + val response = result.getOrThrow() + assertEquals(2, response.courses.size) + assertEquals(1, response.progress.evaluatedCourses) + assertEquals(1, response.progress.pendingCourses) + val evaluatedCourse = response.courses.single { it.kcdm == "CS101" } + val pendingCourse = response.courses.single { it.kcdm == "CS102" } + assertEquals(true, evaluatedCourse.isEvaluated) + assertEquals(false, pendingCourse.isEvaluated) } @Test @@ -266,6 +414,142 @@ class LocalEvaluationServiceBackendTest { assertEquals("操作系统", result.single().courseName) } + @Test + fun `local evaluation service leaves subjective answers empty instead of submitting option ids`() = + runTest { + var submitBody: String? = null + val engine = MockEngine { request -> + when { + request.url.host == "spoc.buaa.edu.cn" && request.url.encodedPath == "/pjxt/cas" -> + respond( + content = ByteReadChannel.Empty, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/html"), + ) + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == "/pjxt/evaluationMethodSix/reviseQuestionnairePattern" -> + respondJson("""{"code":200}""") + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == "/pjxt/evaluationMethodSix/getQuestionnaireTopic" -> + respondJson( + """ + { + "code": 200, + "result": [ + { + "pjxtWjWjbReturnEntity": { + "wjzblist": [ + { + "tklist": [ + { + "tmlx": "1", + "tmid": "q1", + "tmxxlist": [ + {"tmxxid": "optA"}, + {"tmxxid": "optB"} + ] + }, + { + "tmlx": "6", + "tmid": "q7", + "tmxxlist": [ + {"tmxxid": "8290"} + ] + }, + { + "tmlx": "6", + "tmid": "q8", + "tmxxlist": [ + {"tmxxid": "8291"} + ] + } + ] + } + ] + }, + "pjxtPjjgPjjgckb": [ + { + "wjssrwid": "ssrw1", + "bprdm": "T001", + "bprmc": "李老师", + "kcdm": "CS101", + "kcmc": "操作系统", + "pjfs": "1", + "pjid": "pj1", + "pjlx": "2", + "pjrdm": "22373333", + "pjrjsdm": "22373333", + "pjrxm": "测试学生", + "xnxq": "2025-20261", + "sfxxpj": "1" + } + ], + "pjmap": {"source": "test"} + } + ] + } + """ + .trimIndent() + ) + request.url.host == "spoc.buaa.edu.cn" && + request.url.encodedPath == "/pjxt/evaluationMethodSix/submitSaveEvaluation" -> { + submitBody = request.bodyText() + respondJson("""{"code":200,"message":"提交成功","result":{}}""") + } + else -> error("Unexpected url: ${request.url}") + } + } + useMockUpstream(engine) + + val result = + LocalEvaluationServiceBackend() + .submitEvaluations( + listOf( + EvaluationCourse( + id = "rw1_wj1_CS101_T001", + kcmc = "操作系统", + bpmc = "李老师", + rwid = "rw1", + wjid = "wj1", + kcdm = "CS101", + bpdm = "T001", + pjrdm = "22373333", + pjrmc = "测试学生", + xnxq = "2025-20261", + ) + ) + ) + + assertEquals(true, result.single().success) + val answers = + Json.parseToJsonElement(submitBody ?: error("submit body missing")) + .jsonObject["pjjglist"]!! + .jsonArray + .single() + .jsonObject["pjxxlist"]!! + .jsonArray + val subjectiveAnswers = + answers.filter { + it.jsonObject["wjstid"]?.jsonPrimitive?.contentOrNull in setOf("q7", "q8") + } + assertEquals(2, subjectiveAnswers.size) + subjectiveAnswers.forEach { answer -> + val answerObject = answer.jsonObject + assertEquals("6", answerObject["stlx"]?.jsonPrimitive?.contentOrNull) + assertTrue( + answerObject["wjstctid"]?.jsonPrimitive?.contentOrNull in setOf("8290", "8291") + ) + assertEquals(0, answerObject["xxdalist"]?.jsonArray?.size) + } + assertFalse( + subjectiveAnswers.any { answer -> + answer.jsonObject["xxdalist"]!!.jsonArray.any { + it.jsonPrimitive.contentOrNull in setOf("8290", "8291") + } + } + ) + } + private fun useMockUpstream(engine: MockEngine) { LocalUpstreamClientProvider.clientFactory = { followRedirects -> HttpClient(engine) { @@ -276,6 +560,14 @@ class LocalEvaluationServiceBackendTest { } } +private fun io.ktor.client.request.HttpRequestData.bodyText(): String { + return when (val content = body) { + is TextContent -> content.text + is OutgoingContent.ByteArrayContent -> content.bytes().decodeToString() + else -> error("Unsupported body type: ${content::class}") + } +} + private fun MockRequestHandleScope.respondJson(body: String) = respond( content = ByteReadChannel(body), diff --git a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSigninApiBackendTest.kt b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSigninApiBackendTest.kt index 42ce0573..d78de42c 100644 --- a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSigninApiBackendTest.kt +++ b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/LocalSigninApiBackendTest.kt @@ -6,6 +6,7 @@ import cn.edu.ubaa.api.local.LocalAuthSession import cn.edu.ubaa.api.local.LocalAuthSessionStore import cn.edu.ubaa.api.local.LocalCookieStore import cn.edu.ubaa.api.local.LocalUpstreamClientProvider +import cn.edu.ubaa.api.local.LocalWebVpnSupport import cn.edu.ubaa.model.dto.UserData import com.russhwolf.settings.MapSettings import io.ktor.client.HttpClient @@ -256,7 +257,7 @@ class LocalSigninApiBackendTest { headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), ) request.url.toString() == - "http://iclass.buaa.edu.cn:8081/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> + "http://iclass.buaa.edu.cn:8081/eschool/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> respond( content = ByteReadChannel( @@ -329,7 +330,7 @@ class LocalSigninApiBackendTest { headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), ) request.url.toString() == - "http://iclass.buaa.edu.cn:8081/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> + "http://iclass.buaa.edu.cn:8081/eschool/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> respond( content = ByteReadChannel( @@ -445,7 +446,7 @@ class LocalSigninApiBackendTest { headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), ) request.url.toString() == - "http://iclass.buaa.edu.cn:8081/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> + "http://iclass.buaa.edu.cn:8081/eschool/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> respond( content = ByteReadChannel( @@ -511,7 +512,9 @@ class LocalSigninApiBackendTest { ) request.url .toString() - .startsWith("http://iclass.buaa.edu.cn:8081/app/course/stu_scan_sign.action") -> { + .startsWith( + "http://iclass.buaa.edu.cn:8081/eschool/app/course/stu_scan_sign.action" + ) -> { signinRequests++ if (signinRequests == 1) { // First attempt: session expired @@ -547,6 +550,91 @@ class LocalSigninApiBackendTest { assertEquals(2, signinRequests, "should have retried once") } + @Test + fun `webvpn signin uses iclass 8347 endpoint for timestamp and submit`() = runTest { + ConnectionModeStore.save(ConnectionMode.WEBVPN) + ConnectionRuntime.resolveSelectedMode() + LocalAuthSessionStore.save( + LocalAuthSession( + username = "22373333", + user = UserData(name = "Test User", schoolid = "22373333"), + authenticatedAt = "2026-04-20T08:00:00Z", + lastActivity = "2026-04-20T08:30:00Z", + ) + ) + val loginName = "Rjc1QkJDMUMxNzVENkY0NkZCNzFDMEM5RjYwNzg4RDg=" + val observedUpstreamUrls = mutableListOf() + val engine = MockEngine { request -> + val upstreamUrl = LocalWebVpnSupport.fromWebVpnUrl(request.url.toString()) + observedUpstreamUrls += upstreamUrl + when { + upstreamUrl == "https://iclass.buaa.edu.cn:8346/?type=jumpMyCenter" || + upstreamUrl == "https://iclass.buaa.edu.cn:8346?type=jumpMyCenter" -> + respond( + content = ByteReadChannel(""), + status = HttpStatusCode.Found, + headers = + headersOf( + HttpHeaders.Location, + "https://iclass.buaa.edu.cn:8346/?loginName=$loginName&type=jumpMyCenter#/MyCenter", + ), + ) + upstreamUrl.startsWith("https://iclass.buaa.edu.cn:8347/app/user/login.action") -> { + assertEquals(loginName, request.url.parameters["phone"]) + respond( + content = + ByteReadChannel( + """{"STATUS":"0","result":{"id":"user-1","sessionId":"session-1"}}""" + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + upstreamUrl == "https://iclass.buaa.edu.cn:8347/app/common/get_timestamp.action" -> + respond( + content = ByteReadChannel("""{"timestamp":"1713600000"}"""), + status = HttpStatusCode.OK, + headers = + headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + upstreamUrl == + "https://iclass.buaa.edu.cn:8347/eschool/app/course/stu_scan_sign.action?courseSchedId=course-1×tamp=1713600000" -> + respond( + content = + ByteReadChannel( + """{"STATUS":"0","ERRMSG":"签到成功","result":{"stuSignStatus":"1"}}""" + ), + status = HttpStatusCode.OK, + headers = + headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + else -> error("Unexpected url: ${request.url} upstream=$upstreamUrl") + } + } + useMockUpstream(engine) + + val result = SigninApi().performSignin("course-1") + + assertTrue(result.isSuccess, result.exceptionOrNull()?.message.orEmpty()) + assertEquals( + true, + result.getOrNull()?.success, + "response=${result.getOrNull()} observed=$observedUpstreamUrls", + ) + assertTrue( + observedUpstreamUrls.any { + it == "https://iclass.buaa.edu.cn:8347/app/common/get_timestamp.action" + }, + "timestamp should use iclass 8347 in webvpn mode", + ) + assertTrue( + observedUpstreamUrls.any { + it.startsWith("https://iclass.buaa.edu.cn:8347/eschool/app/course/stu_scan_sign.action") + }, + "signin submit should use iclass 8347 eschool endpoint in webvpn mode", + ) + } + private fun useMockUpstream(engine: MockEngine) { LocalUpstreamClientProvider.clientFactory = { followRedirects -> HttpClient(engine) { diff --git a/shared/src/jvmTest/kotlin/cn/edu/ubaa/api/LocalEvaluationRealIntegrationTest.kt b/shared/src/jvmTest/kotlin/cn/edu/ubaa/api/LocalEvaluationRealIntegrationTest.kt new file mode 100644 index 00000000..93eec907 --- /dev/null +++ b/shared/src/jvmTest/kotlin/cn/edu/ubaa/api/LocalEvaluationRealIntegrationTest.kt @@ -0,0 +1,74 @@ +package cn.edu.ubaa.api + +import cn.edu.ubaa.api.core.DefaultApiFactory +import cn.edu.ubaa.api.feature.EvaluationService +import cn.edu.ubaa.api.local.LocalAuthServiceBackend +import cn.edu.ubaa.api.local.LocalAuthSessionStore +import cn.edu.ubaa.api.local.LocalCookieStore +import cn.edu.ubaa.api.local.LocalUpstreamClientProvider +import cn.edu.ubaa.api.storage.CredentialStore +import com.russhwolf.settings.MapSettings +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Assume.assumeTrue + +class LocalEvaluationRealIntegrationTest { + @Test + fun `real local direct account can fetch evaluation list read only`() = runTest { + assumeTrue(System.getenv("UBAA_REAL_EVALUATION_TEST") == "true") + val credentials = loadRealEvaluationCredentials() + + ConnectionModeStore.settings = MapSettings() + LocalAuthSessionStore.settings = MapSettings() + LocalCookieStore.settings = MapSettings() + CredentialStore.settings = MapSettings() + ConnectionRuntime.clearSelectedMode() + ConnectionRuntime.switchMode(ConnectionMode.DIRECT) + ConnectionRuntime.apiFactoryProvider = { DefaultApiFactory } + LocalUpstreamClientProvider.reset() + + val loginResult = + LocalAuthServiceBackend().login(credentials.username, credentials.password, null, null) + assertTrue(loginResult.isSuccess, loginResult.exceptionOrNull()?.message.orEmpty()) + + val evaluationsResult = EvaluationService().getAllEvaluations() + assertTrue(evaluationsResult.isSuccess, evaluationsResult.exceptionOrNull()?.message.orEmpty()) + val evaluations = evaluationsResult.getOrThrow() + println( + "REAL_LOCAL_EVALUATION courses=${evaluations.courses.size} " + + "pending=${evaluations.progress.pendingCourses} " + + "evaluated=${evaluations.progress.evaluatedCourses}" + ) + evaluations.courses.firstOrNull()?.let { course -> + println( + "REAL_LOCAL_EVALUATION first_course=" + + "${course.kcmc}/${course.bpmc}/evaluated=${course.isEvaluated}" + ) + } + } + + private fun loadRealEvaluationCredentials(): RealEvaluationCredentials { + val propertiesPath = + listOf(Path.of("local.properties"), Path.of("../local.properties")) + .firstOrNull(Files::exists) ?: Path.of("local.properties") + assumeTrue( + "local.properties is required for real local evaluation test", + Files.exists(propertiesPath), + ) + val properties = Properties() + Files.newInputStream(propertiesPath).use(properties::load) + val username = properties.getProperty("testuser").orEmpty().trim() + val password = properties.getProperty("testpasswd").orEmpty().trim() + assumeTrue( + "testuser/testpasswd are required for real local evaluation test", + username.isNotBlank() && password.isNotBlank(), + ) + return RealEvaluationCredentials(username, password) + } + + private data class RealEvaluationCredentials(val username: String, val password: String) +}