From cd486a36ef8519587c52440ac41dd0976b1a3f0b Mon Sep 17 00:00:00 2001 From: nanxun Date: Wed, 24 Jun 2026 15:31:32 +0800 Subject: [PATCH] feat: UOOC/Zhihuishu dual-platform brushing platform - ASP.NET Core 9 Web API backend with JWT auth, EF Core MySQL - Vue 3 + Vite + Pinia + Ant Design Vue frontend - Multi-platform connection management (UOOC & Zhihuishu) - Video brushing with AES-CBC encryption for Zhihuishu - Multi-task queue with cross-platform parallel execution - Task persistence via MySQL database - Progress tracking with inline catalog enrichment - Mobile-responsive UI Co-Authored-By: Claude Fable 5 --- .gitignore | 487 +++++ README.md | 133 ++ UoocProgress.sln | 42 + .../Controllers/AdminBrushController.cs | 31 + .../Controllers/AdminController.cs | 300 +++ .../Controllers/AdminNodesController.cs | 33 + .../Controllers/AuthController.cs | 262 +++ .../Controllers/BrushController.cs | 106 + .../Controllers/NodeController.cs | 65 + .../PlatformChallengesController.cs | 37 + .../PlatformConnectionsController.cs | 274 +++ .../Controllers/PlatformsController.cs | 60 + .../Controllers/PublicController.cs | 21 + .../src/UoocProgress.Api/Data/AppDbContext.cs | 186 ++ .../Models/ContractMappings.cs | 222 ++ .../src/UoocProgress.Api/Models/Contracts.cs | 429 ++++ .../src/UoocProgress.Api/Models/Entities.cs | 374 ++++ .../UoocProgress.Api/Models/EnumValueCodec.cs | 303 +++ .../UoocProgress.Api/Models/GatewayResult.cs | 19 + .../Options/BootstrapAdminOptions.cs | 12 + .../UoocProgress.Api/Options/JwtOptions.cs | 14 + .../UoocProgress.Api/Options/UoocOptions.cs | 10 + .../Options/ZhihuishuOptions.cs | 35 + backend/src/UoocProgress.Api/Program.cs | 150 ++ .../Properties/launchSettings.json | 14 + .../Services/BrowserChallengeService.cs | 118 + .../Services/ChallengeSessionService.cs | 87 + .../Services/DatabaseInitializer.cs | 530 +++++ .../Services/EmailVerificationService.cs | 100 + .../Services/JwtTokenService.cs | 43 + .../UoocProgress.Api/Services/MockUoocData.cs | 191 ++ .../UoocProgress.Api/Services/NodeService.cs | 186 ++ .../Services/PlatformConnectionService.cs | 1199 ++++++++++ .../Services/PlatformDefinitionService.cs | 284 +++ .../Services/PlatformWorkflowExecutor.cs | 670 ++++++ .../Services/RuntimeModels.cs | 42 + .../Services/SecretProtectionService.cs | 56 + .../Services/SimpleJsonPathService.cs | 179 ++ .../Services/SystemSettingsService.cs | 74 + .../Services/TemplateResolver.cs | 91 + .../Services/UoocApiService.cs | 222 ++ .../Services/VideoBrushService.cs | 1048 +++++++++ .../Services/ZhihuishuApiService.cs | 965 ++++++++ .../UoocProgress.Api/UoocProgress.Api.csproj | 19 + .../appsettings.Development.json | 8 + backend/src/UoocProgress.Api/appsettings.json | 39 + backend/src/UoocProgress.Node/Program.cs | 161 ++ .../UoocProgress.Node.csproj | 16 + frontend/.env.example | 1 + frontend/index.html | 12 + frontend/package-lock.json | 1938 +++++++++++++++++ frontend/package.json | 25 + frontend/src/App.vue | 18 + frontend/src/components/UoocLoginModal.vue | 110 + .../src/components/ZhihuishuLoginModal.vue | 168 ++ frontend/src/env.d.ts | 1 + frontend/src/layouts/AppShellLayout.vue | 152 ++ frontend/src/lib/api.ts | 402 ++++ frontend/src/lib/storage.ts | 60 + frontend/src/main.ts | 23 + frontend/src/router/index.ts | 202 ++ frontend/src/stores/auth.ts | 155 ++ frontend/src/stores/courses.ts | 144 ++ frontend/src/stores/platform.ts | 249 +++ frontend/src/types/api.ts | 513 +++++ frontend/src/views/ChangePasswordView.vue | 45 + frontend/src/views/CourseSelectionView.vue | 314 +++ frontend/src/views/ForbiddenView.vue | 13 + .../src/views/PlatformConnectionsView.vue | 65 + frontend/src/views/ProfileView.vue | 27 + frontend/src/views/ProgressView.vue | 205 ++ frontend/src/views/admin/AdminInvitesView.vue | 89 + frontend/src/views/admin/AdminNodesView.vue | 102 + .../src/views/admin/AdminPlatformsView.vue | 309 +++ .../src/views/admin/AdminSettingsView.vue | 129 ++ frontend/src/views/admin/AdminTasksView.vue | 87 + frontend/src/views/admin/AdminUsersView.vue | 79 + frontend/src/views/auth/LoginView.vue | 91 + frontend/src/views/auth/RegisterView.vue | 125 ++ frontend/tsconfig.json | 23 + frontend/tsconfig.node.json | 9 + frontend/vite.config.ts | 18 + index.html | 86 + zhihuishu_api_final.md | 306 +++ 84 files changed, 16242 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 UoocProgress.sln create mode 100644 backend/src/UoocProgress.Api/Controllers/AdminBrushController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/AdminController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/AdminNodesController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/AuthController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/BrushController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/NodeController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/PlatformChallengesController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/PlatformConnectionsController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/PlatformsController.cs create mode 100644 backend/src/UoocProgress.Api/Controllers/PublicController.cs create mode 100644 backend/src/UoocProgress.Api/Data/AppDbContext.cs create mode 100644 backend/src/UoocProgress.Api/Models/ContractMappings.cs create mode 100644 backend/src/UoocProgress.Api/Models/Contracts.cs create mode 100644 backend/src/UoocProgress.Api/Models/Entities.cs create mode 100644 backend/src/UoocProgress.Api/Models/EnumValueCodec.cs create mode 100644 backend/src/UoocProgress.Api/Models/GatewayResult.cs create mode 100644 backend/src/UoocProgress.Api/Options/BootstrapAdminOptions.cs create mode 100644 backend/src/UoocProgress.Api/Options/JwtOptions.cs create mode 100644 backend/src/UoocProgress.Api/Options/UoocOptions.cs create mode 100644 backend/src/UoocProgress.Api/Options/ZhihuishuOptions.cs create mode 100644 backend/src/UoocProgress.Api/Program.cs create mode 100644 backend/src/UoocProgress.Api/Properties/launchSettings.json create mode 100644 backend/src/UoocProgress.Api/Services/BrowserChallengeService.cs create mode 100644 backend/src/UoocProgress.Api/Services/ChallengeSessionService.cs create mode 100644 backend/src/UoocProgress.Api/Services/DatabaseInitializer.cs create mode 100644 backend/src/UoocProgress.Api/Services/EmailVerificationService.cs create mode 100644 backend/src/UoocProgress.Api/Services/JwtTokenService.cs create mode 100644 backend/src/UoocProgress.Api/Services/MockUoocData.cs create mode 100644 backend/src/UoocProgress.Api/Services/NodeService.cs create mode 100644 backend/src/UoocProgress.Api/Services/PlatformConnectionService.cs create mode 100644 backend/src/UoocProgress.Api/Services/PlatformDefinitionService.cs create mode 100644 backend/src/UoocProgress.Api/Services/PlatformWorkflowExecutor.cs create mode 100644 backend/src/UoocProgress.Api/Services/RuntimeModels.cs create mode 100644 backend/src/UoocProgress.Api/Services/SecretProtectionService.cs create mode 100644 backend/src/UoocProgress.Api/Services/SimpleJsonPathService.cs create mode 100644 backend/src/UoocProgress.Api/Services/SystemSettingsService.cs create mode 100644 backend/src/UoocProgress.Api/Services/TemplateResolver.cs create mode 100644 backend/src/UoocProgress.Api/Services/UoocApiService.cs create mode 100644 backend/src/UoocProgress.Api/Services/VideoBrushService.cs create mode 100644 backend/src/UoocProgress.Api/Services/ZhihuishuApiService.cs create mode 100644 backend/src/UoocProgress.Api/UoocProgress.Api.csproj create mode 100644 backend/src/UoocProgress.Api/appsettings.Development.json create mode 100644 backend/src/UoocProgress.Api/appsettings.json create mode 100644 backend/src/UoocProgress.Node/Program.cs create mode 100644 backend/src/UoocProgress.Node/UoocProgress.Node.csproj create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/UoocLoginModal.vue create mode 100644 frontend/src/components/ZhihuishuLoginModal.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/layouts/AppShellLayout.vue create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/storage.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/courses.ts create mode 100644 frontend/src/stores/platform.ts create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/views/ChangePasswordView.vue create mode 100644 frontend/src/views/CourseSelectionView.vue create mode 100644 frontend/src/views/ForbiddenView.vue create mode 100644 frontend/src/views/PlatformConnectionsView.vue create mode 100644 frontend/src/views/ProfileView.vue create mode 100644 frontend/src/views/ProgressView.vue create mode 100644 frontend/src/views/admin/AdminInvitesView.vue create mode 100644 frontend/src/views/admin/AdminNodesView.vue create mode 100644 frontend/src/views/admin/AdminPlatformsView.vue create mode 100644 frontend/src/views/admin/AdminSettingsView.vue create mode 100644 frontend/src/views/admin/AdminTasksView.vue create mode 100644 frontend/src/views/admin/AdminUsersView.vue create mode 100644 frontend/src/views/auth/LoginView.vue create mode 100644 frontend/src/views/auth/RegisterView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 index.html create mode 100644 zhihuishu_api_final.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5338d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,487 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +# Frontend build output +frontend/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..49aa5a7 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Uooc Progress Platform + +Vue 3 + ASP.NET Core 学习进度平台,现已拆分为两层身份: + +- 系统自身账号:注册、登录、角色、管理员菜单、权限控制 +- 网课平台会话:登录系统后单独绑定 `uooc_auth`,仅用于读取课程与进度 + +## Features + +- 独立的系统登录页与注册页 +- 左侧后台菜单栏 + 右上角用户头像悬浮菜单 +- 普通用户与管理员分权限菜单 +- MySQL 持久化用户、邀请码、系统设置 +- JWT 系统登录态 +- 单独的网课平台 `uooc_auth` 绑定页 +- 课程选择、进度聚合和上游异常 mock 回退 + +## Project Structure + +- `frontend/`: Vue 3 + Vite + Pinia 前端 +- `backend/src/UoocProgress.Api/`: ASP.NET Core Web API、JWT、EF Core MySQL、静态资源托管 + +## Local Run + +### 1. 准备 MySQL + +创建数据库并修改连接串: + +```sql +CREATE DATABASE uooc_progress_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +默认开发配置位于: + +- [backend/src/UoocProgress.Api/appsettings.Development.json](C:/Users/nanxun/Documents/uooc/backend/src/UoocProgress.Api/appsettings.Development.json) +- [backend/src/UoocProgress.Api/appsettings.json](C:/Users/nanxun/Documents/uooc/backend/src/UoocProgress.Api/appsettings.json) + +首次启动会自动建表,并按 `BootstrapAdmin` 创建种子管理员账号。 + +### 2. 构建前端 + +```powershell +cd C:\Users\nanxun\Documents\uooc\frontend +npm install +npm run build +``` + +### 3. 启动后端 + +```powershell +cd C:\Users\nanxun\Documents\uooc +dotnet run --project .\backend\src\UoocProgress.Api\UoocProgress.Api.csproj +``` + +打开 [http://localhost:5088](http://localhost:5088)。 + +### 4. 分离开发模式 + +```powershell +cd C:\Users\nanxun\Documents\uooc\frontend +npm install +npm run dev + +cd C:\Users\nanxun\Documents\uooc +dotnet run --project .\backend\src\UoocProgress.Api\UoocProgress.Api.csproj +``` + +Vite 会把 `/api` 代理到 `http://localhost:5088`。 + +## Default Admin + +默认管理员配置来自 `BootstrapAdmin`: + +- 用户名:`admin` +- 密码:`Admin123!` + +建议启动后立即登录并修改密码。 + +## Backend Endpoints + +公开接口: + +- `GET /api/public/auth-config` + +系统认证: + +- `POST /api/auth/register` +- `POST /api/auth/login` +- `GET /api/auth/me` +- `POST /api/auth/change-password` + +平台: + +- `GET /api/platforms` +- `GET /api/platforms/{platformId}/schemas/login` +- `GET /api/platforms/{platformId}/schemas/course-query` + +平台连接: + +- `GET /api/platform-connections` +- `POST /api/platform-connections` +- `POST /api/platform-connections/{connectionId}/relogin` +- `POST /api/platform-connections/{connectionId}/activate` +- `DELETE /api/platform-connections/{connectionId}` +- `POST /api/platform-connections/{connectionId}/courses/query` +- `GET /api/platform-connections/{connectionId}/catalog?courseId=...` +- `GET /api/platform-connections/{connectionId}/progress?courseId=...` + +浏览器挑战: + +- `GET /api/platform-challenges/{challengeSessionId}` + +管理员接口: + +- `GET /api/admin/users` +- `PATCH /api/admin/users/{id}` +- `GET /api/admin/invites` +- `POST /api/admin/invites` +- `PATCH /api/admin/invites/{id}` +- `GET /api/admin/settings` +- `PUT /api/admin/settings` +- `GET /api/admin/platforms` +- `POST /api/admin/platforms` +- `GET /api/admin/platforms/{id}` +- `PUT /api/admin/platforms/{id}` +- `PATCH /api/admin/platforms/{id}/status` +- `POST /api/admin/platforms/{id}/clone` + +## Notes + +- 网课平台 `uooc_auth` 不会写入后端数据库,只保存在当前浏览器本地。 +- 不同系统账号会使用不同的本地缓存命名空间,课程关注和进度快照不会串号。 +- 如果没有真实 `uooc_auth`,可以在“网课平台连接”页填写 `mock-session` 体验演示数据。 diff --git a/UoocProgress.sln b/UoocProgress.sln new file mode 100644 index 0000000..fc4e1f9 --- /dev/null +++ b/UoocProgress.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UoocProgress.Api", "backend\src\UoocProgress.Api\UoocProgress.Api.csproj", "{D555E1B7-97A9-4024-B75A-ADDDA4219E49}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Debug|x64.ActiveCfg = Debug|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Debug|x64.Build.0 = Debug|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Debug|x86.ActiveCfg = Debug|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Debug|x86.Build.0 = Debug|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Release|Any CPU.Build.0 = Release|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Release|x64.ActiveCfg = Release|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Release|x64.Build.0 = Release|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Release|x86.ActiveCfg = Release|Any CPU + {D555E1B7-97A9-4024-B75A-ADDDA4219E49}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {D555E1B7-97A9-4024-B75A-ADDDA4219E49} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + EndGlobalSection +EndGlobal diff --git a/backend/src/UoocProgress.Api/Controllers/AdminBrushController.cs b/backend/src/UoocProgress.Api/Controllers/AdminBrushController.cs new file mode 100644 index 0000000..1ac44cf --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/AdminBrushController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "AdminOnly")] +[Route("api/admin/brush")] +public sealed class AdminBrushController(VideoBrushService brushService) : ControllerBase +{ + [HttpGet("tasks")] + public ActionResult> GetTasks() + => Ok(brushService.GetAllTasks()); + + [HttpPost("stop/{userId:long}")] + public IActionResult Stop(long userId) { brushService.AdminStop(userId); return NoContent(); } + + [HttpPost("stop-all")] + public IActionResult StopAll() { brushService.StopAll(); return NoContent(); } + + [HttpGet("config")] + public ActionResult GetConfig() => Ok(brushService.Config); + + [HttpPut("config")] + public ActionResult UpdateConfig([FromBody] BrushSystemConfig config) + { + brushService.Config.PauseNewTasks = config.PauseNewTasks; + return Ok(brushService.Config); + } +} diff --git a/backend/src/UoocProgress.Api/Controllers/AdminController.cs b/backend/src/UoocProgress.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..ccc103d --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/AdminController.cs @@ -0,0 +1,300 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "AdminOnly")] +public sealed class AdminController( + AppDbContext dbContext, + SystemSettingsService settingsService, + PlatformDefinitionService platformDefinitionService) : ControllerBase +{ + [HttpGet("api/admin/users")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetUsers(CancellationToken cancellationToken) + { + var users = await dbContext.Users + .AsNoTracking() + .OrderByDescending(item => item.CreatedAt) + .ToListAsync(cancellationToken); + + return Ok(users.Select(item => item.ToDto()).ToList()); + } + + [HttpPatch("api/admin/users/{id:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateUser( + long id, + [FromBody] UpdateUserRequest request, + CancellationToken cancellationToken) + { + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == id, cancellationToken); + if (user is null) + { + return NotFound(CreateProblem("用户不存在。", StatusCodes.Status404NotFound)); + } + + var currentUserId = TryGetCurrentUserId(); + if (!string.IsNullOrWhiteSpace(request.DisplayName)) + { + user.DisplayName = request.DisplayName.Trim(); + } + + if (!string.IsNullOrWhiteSpace(request.Role)) + { + if (!EnumValueCodec.TryParseUserRole(request.Role, out var role)) + { + return BadRequest(CreateProblem("role 仅支持 user 或 admin。", StatusCodes.Status400BadRequest)); + } + + if (currentUserId == user.Id && user.Role == UserRole.Admin && role != UserRole.Admin) + { + return BadRequest(CreateProblem("不能取消当前管理员自己的管理员角色。", StatusCodes.Status400BadRequest)); + } + + user.Role = role; + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + if (!EnumValueCodec.TryParseUserStatus(request.Status, out var status)) + { + return BadRequest(CreateProblem("status 仅支持 active 或 disabled。", StatusCodes.Status400BadRequest)); + } + + if (currentUserId == user.Id && status == UserStatus.Disabled) + { + return BadRequest(CreateProblem("不能停用当前管理员自己的账号。", StatusCodes.Status400BadRequest)); + } + + user.Status = status; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(user.ToDto()); + } + + [HttpGet("api/admin/invites")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetInvites(CancellationToken cancellationToken) + { + var invites = await dbContext.InviteCodes + .Include(item => item.CreatedByUser) + .AsNoTracking() + .OrderByDescending(item => item.CreatedAt) + .ToListAsync(cancellationToken); + + return Ok(invites.Select(item => item.ToDto(item.CreatedByUser?.DisplayName ?? item.CreatedByUser?.Username ?? "管理员")).ToList()); + } + + [HttpPost("api/admin/invites")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateInvite( + [FromBody] CreateInviteCodeRequest request, + CancellationToken cancellationToken) + { + var currentUserId = TryGetCurrentUserId(); + if (currentUserId is null) + { + return Unauthorized(CreateProblem("当前登录态无效。", StatusCodes.Status401Unauthorized)); + } + + var maxUses = Math.Max(1, request.MaxUses); + var code = string.IsNullOrWhiteSpace(request.Code) + ? await GenerateInviteCodeAsync(cancellationToken) + : request.Code.Trim().ToUpperInvariant(); + var normalizedCode = DatabaseInitializer.Normalize(code); + + if (await dbContext.InviteCodes.AnyAsync(item => item.CodeNormalized == normalizedCode, cancellationToken)) + { + return BadRequest(CreateProblem("邀请码已存在,请更换。", StatusCodes.Status400BadRequest)); + } + + var invite = new InviteCodeRecord + { + Code = code, + CodeNormalized = normalizedCode, + Status = InviteCodeStatus.Active, + MaxUses = maxUses, + UsedCount = 0, + ExpiresAt = request.ExpiresAt, + CreatedByUserId = currentUserId.Value, + CreatedAt = DateTimeOffset.UtcNow + }; + + dbContext.InviteCodes.Add(invite); + await dbContext.SaveChangesAsync(cancellationToken); + + var creator = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == currentUserId.Value, cancellationToken); + return Ok(invite.ToDto(creator.DisplayName)); + } + + [HttpPatch("api/admin/invites/{id:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateInvite( + long id, + [FromBody] UpdateInviteCodeRequest request, + CancellationToken cancellationToken) + { + var invite = await dbContext.InviteCodes + .Include(item => item.CreatedByUser) + .SingleOrDefaultAsync(item => item.Id == id, cancellationToken); + + if (invite is null) + { + return NotFound(CreateProblem("邀请码不存在。", StatusCodes.Status404NotFound)); + } + + if (!EnumValueCodec.TryParseInviteCodeStatus(request.Status, out var status)) + { + return BadRequest(CreateProblem("status 仅支持 active 或 disabled。", StatusCodes.Status400BadRequest)); + } + + invite.Status = status; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(invite.ToDto(invite.CreatedByUser?.DisplayName ?? invite.CreatedByUser?.Username ?? "管理员")); + } + + [HttpGet("api/admin/settings")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetSettings(CancellationToken cancellationToken) => + Ok(await settingsService.GetDtoAsync(cancellationToken)); + + [HttpPut("api/admin/settings")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> UpdateSettings( + [FromBody] UpdateSystemSettingRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await settingsService.UpdateAsync(request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpGet("api/admin/platforms")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetPlatforms(CancellationToken cancellationToken) => + Ok(await platformDefinitionService.GetAdminListAsync(cancellationToken)); + + [HttpPost("api/admin/platforms")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreatePlatform( + [FromBody] SavePlatformDefinitionRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformDefinitionService.CreateAsync(request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpGet("api/admin/platforms/{id:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlatform(long id, CancellationToken cancellationToken) + { + var platform = await platformDefinitionService.GetByIdAsync(id, cancellationToken); + return platform is null + ? NotFound(CreateProblem("平台不存在。", StatusCodes.Status404NotFound)) + : Ok(platform); + } + + [HttpPut("api/admin/platforms/{id:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> UpdatePlatform( + long id, + [FromBody] SavePlatformDefinitionRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformDefinitionService.UpdateAsync(id, request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpPatch("api/admin/platforms/{id:long}/status")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> UpdatePlatformStatus( + long id, + [FromBody] PlatformStatusPatchRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformDefinitionService.UpdateStatusAsync(id, request.Status, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpPost("api/admin/platforms/{id:long}/clone")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> ClonePlatform(long id, CancellationToken cancellationToken) + { + try + { + return Ok(await platformDefinitionService.CloneAsync(id, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + private async Task GenerateInviteCodeAsync(CancellationToken cancellationToken) + { + while (true) + { + var code = $"INV-{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + if (!await dbContext.InviteCodes.AnyAsync(item => item.CodeNormalized == DatabaseInitializer.Normalize(code), cancellationToken)) + { + return code; + } + } + } + + private long? TryGetCurrentUserId() + { + var claimValue = User.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(claimValue, out var userId) ? userId : null; + } + + private static ProblemDetails CreateProblem(string detail, int statusCode) => + new() + { + Title = "管理员请求失败", + Detail = detail, + Status = statusCode + }; +} diff --git a/backend/src/UoocProgress.Api/Controllers/AdminNodesController.cs b/backend/src/UoocProgress.Api/Controllers/AdminNodesController.cs new file mode 100644 index 0000000..0bc70bf --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/AdminNodesController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "AdminOnly")] +[Route("api/admin/nodes")] +public sealed class AdminNodesController(NodeService nodeService) : ControllerBase +{ + [HttpGet] + public async Task>> GetNodes() => Ok(await nodeService.GetNodesAsync()); + + [HttpPost("token")] + public ActionResult GenerateToken() + { + var token = NodeService.GenerateToken(); + return Ok(new TokenResponse(token)); + } + + [HttpDelete("{nodeId:long}")] + public async Task Delete(long nodeId) { await nodeService.DeleteNodeAsync(nodeId); return NoContent(); } + + [HttpGet("tasks")] + public async Task>> GetTasks() => Ok(await nodeService.GetTasksAsync()); + + [HttpPost("tasks/{taskId:long}/cancel")] + public async Task CancelTask(long taskId) { await nodeService.CancelTaskAsync(taskId); return NoContent(); } + + public sealed record TokenResponse(string Token); +} diff --git a/backend/src/UoocProgress.Api/Controllers/AuthController.cs b/backend/src/UoocProgress.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..209d7cb --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/AuthController.cs @@ -0,0 +1,262 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController( + AppDbContext dbContext, + PasswordHasher passwordHasher, + JwtTokenService jwtTokenService, + SystemSettingsService settingsService, + EmailVerificationService emailVerificationService) : ControllerBase +{ + [HttpPost("send-email-code")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SendEmailCode( + [FromBody] SendEmailCodeRequest request, + CancellationToken cancellationToken) + { + var settings = await settingsService.GetEntityAsync(cancellationToken); + if (!settings.RequireEmailVerification) + { + return BadRequest(CreateProblem("当前未开启邮箱验证。", StatusCodes.Status400BadRequest)); + } + + try + { + await emailVerificationService.SendCodeAsync(request.Email, cancellationToken); + return NoContent(); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Register( + [FromBody] RegisterRequest request, + CancellationToken cancellationToken) + { + var validationMessage = ValidateCredentials(request.Username, request.DisplayName, request.Password); + if (validationMessage is not null) + { + return BadRequest(CreateProblem(validationMessage, StatusCodes.Status400BadRequest)); + } + + var normalizedUsername = DatabaseInitializer.Normalize(request.Username); + var exists = await dbContext.Users.AnyAsync(item => item.UsernameNormalized == normalizedUsername, cancellationToken); + if (exists) + { + return Conflict(CreateProblem("该用户名已存在。", StatusCodes.Status409Conflict)); + } + + var settings = await settingsService.GetEntityAsync(cancellationToken); + + var email = request.Email?.Trim(); + if (settings.RequireEmailVerification) + { + if (string.IsNullOrWhiteSpace(email)) + { + return BadRequest(CreateProblem("请填写邮箱地址。", StatusCodes.Status400BadRequest)); + } + + if (string.IsNullOrWhiteSpace(request.EmailCode)) + { + return BadRequest(CreateProblem("请填写邮箱验证码。", StatusCodes.Status400BadRequest)); + } + + if (!emailVerificationService.Verify(email, request.EmailCode)) + { + return BadRequest(CreateProblem("邮箱验证码错误或已过期。", StatusCodes.Status400BadRequest)); + } + } + + InviteCodeRecord? invite = null; + var inviteCode = request.InviteCode?.Trim(); + if (settings.RegistrationMode == RegistrationMode.InviteOnly || !string.IsNullOrWhiteSpace(inviteCode)) + { + if (string.IsNullOrWhiteSpace(inviteCode)) + { + return BadRequest(CreateProblem("当前注册模式需要邀请码。", StatusCodes.Status400BadRequest)); + } + + invite = await dbContext.InviteCodes.SingleOrDefaultAsync( + item => item.CodeNormalized == DatabaseInitializer.Normalize(inviteCode), + cancellationToken); + + if (invite is null || !IsInviteUsable(invite)) + { + return BadRequest(CreateProblem("邀请码不可用、已过期或已达到使用上限。", StatusCodes.Status400BadRequest)); + } + } + + var user = new UserAccount + { + Username = request.Username.Trim(), + UsernameNormalized = normalizedUsername, + DisplayName = request.DisplayName.Trim(), + Email = string.IsNullOrWhiteSpace(email) ? null : email, + Role = UserRole.User, + Status = UserStatus.Active, + CreatedAt = DateTimeOffset.UtcNow + }; + + user.PasswordHash = passwordHasher.HashPassword(user, request.Password.Trim()); + dbContext.Users.Add(user); + + if (invite is not null) + { + invite.UsedCount += 1; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(jwtTokenService.Create(user)); + } + + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Login( + [FromBody] LoginRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + { + return BadRequest(CreateProblem("请输入用户名和密码。", StatusCodes.Status400BadRequest)); + } + + var normalizedUsername = DatabaseInitializer.Normalize(request.Username); + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.UsernameNormalized == normalizedUsername, cancellationToken); + if (user is null) + { + return Unauthorized(CreateProblem("用户名或密码错误。", StatusCodes.Status401Unauthorized)); + } + + if (user.Status == UserStatus.Disabled) + { + return StatusCode(StatusCodes.Status403Forbidden, CreateProblem("该账号已被停用。", StatusCodes.Status403Forbidden)); + } + + var verification = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password.Trim()); + if (verification == PasswordVerificationResult.Failed) + { + return Unauthorized(CreateProblem("用户名或密码错误。", StatusCodes.Status401Unauthorized)); + } + + if (verification == PasswordVerificationResult.SuccessRehashNeeded) + { + user.PasswordHash = passwordHasher.HashPassword(user, request.Password.Trim()); + } + + user.LastLoginAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(jwtTokenService.Create(user)); + } + + [Authorize(Policy = "UserOrAdmin")] + [HttpGet("me")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Me(CancellationToken cancellationToken) + { + var user = await FindCurrentUserAsync(cancellationToken); + if (user is null) + { + return Unauthorized(CreateProblem("当前登录态无效,请重新登录。", StatusCodes.Status401Unauthorized)); + } + + return Ok(user.ToDto()); + } + + [Authorize(Policy = "UserOrAdmin")] + [HttpPost("change-password")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ChangePassword( + [FromBody] ChangePasswordRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword)) + { + return BadRequest(CreateProblem("请输入当前密码和新密码。", StatusCodes.Status400BadRequest)); + } + + if (request.NewPassword.Trim().Length < 6) + { + return BadRequest(CreateProblem("新密码长度至少为 6 位。", StatusCodes.Status400BadRequest)); + } + + var user = await FindCurrentUserAsync(cancellationToken); + if (user is null) + { + return Unauthorized(CreateProblem("当前登录态无效,请重新登录。", StatusCodes.Status401Unauthorized)); + } + + var verification = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.CurrentPassword.Trim()); + if (verification == PasswordVerificationResult.Failed) + { + return BadRequest(CreateProblem("当前密码不正确。", StatusCodes.Status400BadRequest)); + } + + user.PasswordHash = passwordHasher.HashPassword(user, request.NewPassword.Trim()); + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private async Task FindCurrentUserAsync(CancellationToken cancellationToken) + { + var claimValue = User.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(claimValue, out var userId) + ? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == userId, cancellationToken) + : null; + } + + private static bool IsInviteUsable(InviteCodeRecord invite) => + invite.Status == InviteCodeStatus.Active + && invite.UsedCount < invite.MaxUses + && (invite.ExpiresAt is null || invite.ExpiresAt > DateTimeOffset.UtcNow); + + private static string? ValidateCredentials(string username, string displayName, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(displayName) || string.IsNullOrWhiteSpace(password)) + { + return "用户名、显示名和密码不能为空。"; + } + + if (username.Trim().Length < 3) + { + return "用户名长度至少为 3 位。"; + } + + if (password.Trim().Length < 6) + { + return "密码长度至少为 6 位。"; + } + + return null; + } + + private static ProblemDetails CreateProblem(string detail, int statusCode) => + new() + { + Title = "认证请求失败", + Detail = detail, + Status = statusCode + }; +} diff --git a/backend/src/UoocProgress.Api/Controllers/BrushController.cs b/backend/src/UoocProgress.Api/Controllers/BrushController.cs new file mode 100644 index 0000000..4073d3d --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/BrushController.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "UserOrAdmin")] +[Route("api/brush")] +public sealed class BrushController( + VideoBrushService brushService, + PlatformConnectionService connectionService, + AppDbContext dbContext) : ControllerBase +{ + [HttpPost("start")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Start( + [FromBody] StartBrushRequest request, + CancellationToken cancellationToken) + { + var userId = GetUserId(); + var sessionData = await connectionService.GetActiveSessionDataAsync(userId, cancellationToken); + + // Determine platform slug from the active connection + var connection = await dbContext.UserPlatformConnections + .Include(c => c.PlatformDefinition) + .FirstOrDefaultAsync(c => c.UserAccountId == userId && c.IsActive, cancellationToken); + var platformSlug = connection?.PlatformDefinition?.Slug ?? "uooc"; + + return Ok(brushService.Start(userId, 0, request.CourseId, platformSlug, request.Chapters, sessionData)); + } + + [HttpGet("status")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public ActionResult> GetStatus() + { + return Ok(brushService.GetStatus(GetUserId())); + } + + [HttpPost("stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public IActionResult Stop() + { + brushService.Stop(GetUserId()); + return NoContent(); + } + + [HttpPost("{taskId}/stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public IActionResult StopTask(string taskId) + { + brushService.StopTask(GetUserId(), taskId); + return NoContent(); + } + + [HttpPost("retry")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult Retry() + { + try + { + return Ok(brushService.Retry(GetUserId())); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ProblemDetails { Title = "重试失败", Detail = ex.Message, Status = 400 }); + } + } + + [HttpPost("{taskId}/retry")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult RetryTask(string taskId) + { + try + { + return Ok(brushService.RetryTask(GetUserId(), taskId)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ProblemDetails { Title = "重试失败", Detail = ex.Message, Status = 400 }); + } + } + + [HttpDelete("{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public IActionResult DeleteTask(string taskId) + { + brushService.DeleteTask(GetUserId(), taskId); + return NoContent(); + } + + private long GetUserId() + { + var claim = User.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(claim, out var id) ? id + : throw new InvalidOperationException("登录态无效。"); + } +} + +public sealed record StartBrushRequest(string CourseId, List Chapters); diff --git a/backend/src/UoocProgress.Api/Controllers/NodeController.cs b/backend/src/UoocProgress.Api/Controllers/NodeController.cs new file mode 100644 index 0000000..a47e98e --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/NodeController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +/// +/// Endpoints called by automation nodes (token auth) — no JWT required. +/// +[ApiController] +[Route("api/node")] +public sealed class NodeController(NodeService nodeService) : ControllerBase +{ + [HttpPost("register")] + public async Task> Register([FromBody] NodeRegisterRequest req) + { + var node = await nodeService.RegisterAsync(req.Name, req.Token, HttpContext.Connection.RemoteIpAddress?.ToString()); + return Ok(new NodeRegisterResponse(node.Id)); + } + + [HttpPost("heartbeat")] + public async Task Heartbeat([FromBody] NodeHeartbeatRequest req) + { + await nodeService.HeartbeatAsync(req.NodeId, HttpContext.Connection.RemoteIpAddress?.ToString()); + return NoContent(); + } + + [HttpGet("poll")] + public async Task> Poll([FromQuery] long nodeId) + { + var task = await nodeService.PollAsync(nodeId); + if (task is null) return Ok(new { task = (object?)null }); + return Ok(new NodeTaskResponse(task.Id, task.CourseId, task.CourseName, task.PlatformUrl, task.TaskDataJson, task.TotalSteps)); + } + + [HttpPost("progress")] + public async Task ReportProgress([FromBody] NodeProgressRequest req) + { + await nodeService.UpdateProgressAsync(req.TaskId, req.CompletedSteps, req.CurrentStep, req.LastError); + return NoContent(); + } + + [HttpPost("complete")] + public async Task Complete([FromBody] NodeCompleteRequest req) + { + await nodeService.CompleteAsync(req.TaskId); + return NoContent(); + } + + [HttpPost("fail")] + public async Task Fail([FromBody] NodeFailRequest req) + { + await nodeService.FailAsync(req.TaskId, req.Error); + return NoContent(); + } + + public sealed record NodeRegisterRequest(string Name, string Token); + public sealed record NodeRegisterResponse(long NodeId); + public sealed record NodeHeartbeatRequest(long NodeId); + public sealed record NodeTaskResponse(long TaskId, string CourseId, string CourseName, string PlatformUrl, string TaskDataJson, int TotalSteps); + public sealed record NodeProgressRequest(long TaskId, int CompletedSteps, string? CurrentStep, string? LastError); + public sealed record NodeCompleteRequest(long TaskId); + public sealed record NodeFailRequest(long TaskId, string Error); +} diff --git a/backend/src/UoocProgress.Api/Controllers/PlatformChallengesController.cs b/backend/src/UoocProgress.Api/Controllers/PlatformChallengesController.cs new file mode 100644 index 0000000..3c48c80 --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/PlatformChallengesController.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "UserOrAdmin")] +[Route("api/platform-challenges")] +public sealed class PlatformChallengesController(ChallengeSessionService challengeSessionService) : ControllerBase +{ + [HttpGet("{challengeSessionId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetChallenge(string challengeSessionId) + { + var claimValue = User.FindFirstValue(ClaimTypes.NameIdentifier); + var userId = long.TryParse(claimValue, out var parsed) + ? parsed + : throw new InvalidOperationException("当前登录态无效。"); + + var challenge = challengeSessionService.Get(challengeSessionId, userId); + if (challenge is null) + { + return NotFound(new ProblemDetails + { + Title = "挑战会话不存在", + Detail = "未找到对应的挑战会话。", + Status = StatusCodes.Status404NotFound + }); + } + + return Ok(challenge); + } +} diff --git a/backend/src/UoocProgress.Api/Controllers/PlatformConnectionsController.cs b/backend/src/UoocProgress.Api/Controllers/PlatformConnectionsController.cs new file mode 100644 index 0000000..bbd8685 --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/PlatformConnectionsController.cs @@ -0,0 +1,274 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "UserOrAdmin")] +[Route("api/platform-connections")] +public sealed class PlatformConnectionsController(PlatformConnectionService platformConnectionService) : ControllerBase +{ + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetConnections(CancellationToken cancellationToken) => + Ok(await platformConnectionService.GetConnectionsAsync(GetCurrentUserId(), cancellationToken)); + + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task> StartLogin( + [FromBody] PlatformLoginStartRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformConnectionService.StartLoginAsync(GetCurrentUserId(), request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + return ToPlatformProblem(exception); + } + } + + [HttpPost("{connectionId:long}/relogin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Relogin( + long connectionId, + [FromBody] PlatformReloginRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformConnectionService.ReloginAsync(GetCurrentUserId(), connectionId, request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + return ToPlatformProblem(exception); + } + } + + [HttpPost("{connectionId:long}/activate")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Activate(long connectionId, CancellationToken cancellationToken) + { + try + { + await platformConnectionService.ActivateAsync(GetCurrentUserId(), connectionId, cancellationToken); + return NoContent(); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpDelete("{connectionId:long}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Delete(long connectionId, CancellationToken cancellationToken) + { + try + { + await platformConnectionService.DeleteAsync(GetCurrentUserId(), connectionId, cancellationToken); + return NoContent(); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + } + + [HttpPost("uooc-login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task> UoocLogin( + [FromBody] UoocLoginRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformConnectionService.UoocLoginAsync(GetCurrentUserId(), request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + if (exception.IsUnauthorized) + { + return Unauthorized(CreateProblem(exception.Message, StatusCodes.Status401Unauthorized)); + } + + return StatusCode(StatusCodes.Status502BadGateway, CreateProblem(exception.Message, StatusCodes.Status502BadGateway)); + } + } + + [HttpPost("zhihuishu-login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task> ZhihuishuLogin( + [FromBody] ZhihuishuLoginRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformConnectionService.ZhihuishuLoginAsync(GetCurrentUserId(), request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + if (exception.IsUnauthorized) + { + return Unauthorized(CreateProblem(exception.Message, StatusCodes.Status401Unauthorized)); + } + + return StatusCode(StatusCodes.Status502BadGateway, CreateProblem(exception.Message, StatusCodes.Status502BadGateway)); + } + } + + [HttpPost("{connectionId:long}/courses/query")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> QueryCourses( + long connectionId, + [FromBody] PlatformCourseQueryRequest request, + CancellationToken cancellationToken) + { + try + { + return Ok(await platformConnectionService.QueryCoursesAsync(GetCurrentUserId(), connectionId, request, cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + return ToPlatformProblem(exception); + } + } + + [HttpGet("{connectionId:long}/catalog")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetCatalog( + long connectionId, + [FromQuery] string courseId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(courseId)) + { + return BadRequest(CreateProblem("courseId 不能为空。", StatusCodes.Status400BadRequest)); + } + + try + { + return Ok(await platformConnectionService.GetCatalogAsync(GetCurrentUserId(), connectionId, courseId.Trim(), cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + return ToPlatformProblem(exception); + } + } + + [HttpGet("{connectionId:long}/progress")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetProgress( + long connectionId, + [FromQuery] string courseId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(courseId)) + { + return BadRequest(CreateProblem("courseId 不能为空。", StatusCodes.Status400BadRequest)); + } + + try + { + return Ok(await platformConnectionService.GetProgressAsync(GetCurrentUserId(), connectionId, courseId.Trim(), cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + return ToPlatformProblem(exception); + } + } + + [HttpGet("{connectionId:long}/units")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetUnits( + long connectionId, + [FromQuery] string courseId, + [FromQuery] string chapterId, + [FromQuery] string sectionId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(courseId) || string.IsNullOrWhiteSpace(chapterId) || string.IsNullOrWhiteSpace(sectionId)) + { + return BadRequest(CreateProblem("courseId、chapterId、sectionId 不能为空。", StatusCodes.Status400BadRequest)); + } + + try + { + return Ok(await platformConnectionService.GetUnitsAsync(GetCurrentUserId(), connectionId, courseId.Trim(), chapterId.Trim(), sectionId.Trim(), cancellationToken)); + } + catch (InvalidOperationException exception) + { + return BadRequest(CreateProblem(exception.Message, StatusCodes.Status400BadRequest)); + } + catch (PlatformOperationException exception) + { + return ToPlatformProblem(exception); + } + } + + private long GetCurrentUserId() + { + var claimValue = User.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(claimValue, out var userId) + ? userId + : throw new InvalidOperationException("当前登录态无效。"); + } + + private ActionResult ToPlatformProblem(PlatformOperationException exception) + { + if (exception.IsUnauthorized) + { + Response.Headers["X-Auth-Error"] = "platform"; + return Unauthorized(CreateProblem(exception.Message, StatusCodes.Status401Unauthorized)); + } + + return StatusCode(StatusCodes.Status502BadGateway, CreateProblem(exception.Message, StatusCodes.Status502BadGateway)); + } + + private static ProblemDetails CreateProblem(string detail, int statusCode) => + new() + { + Title = "平台连接请求失败", + Detail = detail, + Status = statusCode + }; +} diff --git a/backend/src/UoocProgress.Api/Controllers/PlatformsController.cs b/backend/src/UoocProgress.Api/Controllers/PlatformsController.cs new file mode 100644 index 0000000..d4cb527 --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/PlatformsController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Authorize(Policy = "UserOrAdmin")] +[Route("api/platforms")] +public sealed class PlatformsController( + PlatformDefinitionService platformDefinitionService, + SystemSettingsService settingsService) : ControllerBase +{ + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetPlatforms(CancellationToken cancellationToken) + { + var settings = await settingsService.GetEntityAsync(cancellationToken); + return Ok(await platformDefinitionService.GetActiveListAsync(settings.DefaultPlatformVisibility, cancellationToken)); + } + + [HttpGet("{platformId:long}/schemas/login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetLoginSchema(long platformId, CancellationToken cancellationToken) => + await GetSchemaAsync(platformId, PlatformFieldScope.Login, "login", cancellationToken); + + [HttpGet("{platformId:long}/schemas/course-query")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCourseQuerySchema(long platformId, CancellationToken cancellationToken) => + await GetSchemaAsync(platformId, PlatformFieldScope.CourseQuery, "course_query", cancellationToken); + + private async Task> GetSchemaAsync( + long platformId, + PlatformFieldScope scope, + string scopeValue, + CancellationToken cancellationToken) + { + var platform = await platformDefinitionService.FindEntityAsync(platformId, cancellationToken); + if (platform is null || platform.Status != PlatformStatus.Active) + { + return NotFound(new ProblemDetails + { + Title = "平台不存在", + Detail = "平台不存在或尚未启用。", + Status = StatusCodes.Status404NotFound + }); + } + + var fields = platform.FieldDefinitions + .Where(item => item.Scope == scope) + .OrderBy(item => item.DisplayOrder) + .Select(item => item.ToDto()) + .ToList(); + + return Ok(new PlatformSchemaDto(platform.Id, platform.DisplayName, scopeValue, fields)); + } +} diff --git a/backend/src/UoocProgress.Api/Controllers/PublicController.cs b/backend/src/UoocProgress.Api/Controllers/PublicController.cs new file mode 100644 index 0000000..5bae99c --- /dev/null +++ b/backend/src/UoocProgress.Api/Controllers/PublicController.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using UoocProgress.Api.Models; +using UoocProgress.Api.Services; + +namespace UoocProgress.Api.Controllers; + +[ApiController] +[Route("api/public")] +public sealed class PublicController(SystemSettingsService settingsService) : ControllerBase +{ + [HttpGet("auth-config")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAuthConfig(CancellationToken cancellationToken) + { + var settings = await settingsService.GetEntityAsync(cancellationToken); + return Ok(new PublicAuthConfigResponse( + EnumValueCodec.ToApiValue(settings.RegistrationMode), + settings.SystemName, + settings.RequireEmailVerification)); + } +} diff --git a/backend/src/UoocProgress.Api/Data/AppDbContext.cs b/backend/src/UoocProgress.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..8b417f0 --- /dev/null +++ b/backend/src/UoocProgress.Api/Data/AppDbContext.cs @@ -0,0 +1,186 @@ +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Data; + +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + + public DbSet InviteCodes => Set(); + + public DbSet SystemSettings => Set(); + + public DbSet PlatformDefinitions => Set(); + + public DbSet PlatformFieldDefinitions => Set(); + + public DbSet PlatformWorkflowSteps => Set(); + + public DbSet UserPlatformConnections => Set(); + + public DbSet AutomationNodes => Set(); + + public DbSet NodeTasks => Set(); + + public DbSet BrushTasks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("users"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Username).HasMaxLength(64).IsRequired(); + entity.Property(item => item.UsernameNormalized).HasMaxLength(64).IsRequired(); + entity.Property(item => item.DisplayName).HasMaxLength(64).IsRequired(); + entity.Property(item => item.Email).HasMaxLength(256); + entity.Property(item => item.PasswordHash).HasMaxLength(512).IsRequired(); + entity.Property(item => item.Role).HasConversion().HasMaxLength(16).IsRequired(); + entity.Property(item => item.Status).HasConversion().HasMaxLength(16).IsRequired(); + entity.HasIndex(item => item.UsernameNormalized).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("invite_codes"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Code).HasMaxLength(64).IsRequired(); + entity.Property(item => item.CodeNormalized).HasMaxLength(64).IsRequired(); + entity.Property(item => item.Status).HasConversion().HasMaxLength(16).IsRequired(); + entity.HasIndex(item => item.CodeNormalized).IsUnique(); + entity.HasOne(item => item.CreatedByUser) + .WithMany(user => user.CreatedInviteCodes) + .HasForeignKey(item => item.CreatedByUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("system_settings"); + entity.HasKey(item => item.Id); + entity.Property(item => item.SystemName).HasMaxLength(128).IsRequired(); + entity.Property(item => item.RegistrationMode).HasConversion().HasMaxLength(16).IsRequired(); + entity.Property(item => item.DefaultPlatformVisibility).HasMaxLength(32).IsRequired(); + entity.Property(item => item.SmtpHost).HasMaxLength(256); + entity.Property(item => item.SmtpUsername).HasMaxLength(256); + entity.Property(item => item.SmtpPassword).HasMaxLength(512); + entity.Property(item => item.SmtpFromEmail).HasMaxLength(256); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("platform_definitions"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Slug).HasMaxLength(64).IsRequired(); + entity.Property(item => item.DisplayName).HasMaxLength(128).IsRequired(); + entity.Property(item => item.Description).HasMaxLength(1024).IsRequired(); + entity.Property(item => item.Status).HasConversion().HasMaxLength(16).IsRequired(); + entity.Property(item => item.CourseQueryStepKey).HasMaxLength(64); + entity.HasIndex(item => item.Slug).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("platform_field_definitions"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Scope).HasConversion().HasMaxLength(24).IsRequired(); + entity.Property(item => item.Key).HasMaxLength(64).IsRequired(); + entity.Property(item => item.Label).HasMaxLength(128).IsRequired(); + entity.Property(item => item.Type).HasConversion().HasMaxLength(24).IsRequired(); + entity.Property(item => item.Placeholder).HasMaxLength(256); + entity.Property(item => item.HelpText).HasMaxLength(512); + entity.Property(item => item.DefaultValue).HasMaxLength(512); + entity.HasIndex(item => new { item.PlatformDefinitionId, item.Scope, item.Key }).IsUnique(); + entity.HasOne(item => item.PlatformDefinition) + .WithMany(platform => platform.FieldDefinitions) + .HasForeignKey(item => item.PlatformDefinitionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("platform_workflow_steps"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Scope).HasConversion().HasMaxLength(24).IsRequired(); + entity.Property(item => item.StepKey).HasMaxLength(64).IsRequired(); + entity.Property(item => item.DisplayName).HasMaxLength(128).IsRequired(); + entity.Property(item => item.StepType).HasConversion().HasMaxLength(32).IsRequired(); + entity.Property(item => item.HttpMethod).HasMaxLength(16).IsRequired(); + entity.Property(item => item.ContentType).HasMaxLength(64); + entity.Property(item => item.SuccessPath).HasMaxLength(256); + entity.Property(item => item.SuccessExpectedValue).HasMaxLength(256); + entity.Property(item => item.PlatformUserLabelExpression).HasMaxLength(256); + entity.Property(item => item.BrowserSuccessUrlContains).HasMaxLength(512); + entity.Property(item => item.BrowserSuccessCookieName).HasMaxLength(128); + entity.Property(item => item.BrowserWaitForSelector).HasMaxLength(256); + entity.Property(item => item.BrowserAutomationJson).HasMaxLength(8192); + entity.HasIndex(item => new { item.PlatformDefinitionId, item.Scope, item.StepKey }).IsUnique(); + entity.HasOne(item => item.PlatformDefinition) + .WithMany(platform => platform.WorkflowSteps) + .HasForeignKey(item => item.PlatformDefinitionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("user_platform_connections"); + entity.HasKey(item => item.Id); + entity.Property(item => item.ConnectionName).HasMaxLength(128).IsRequired(); + entity.Property(item => item.PlatformUserLabel).HasMaxLength(256); + entity.Property(item => item.Status).HasConversion().HasMaxLength(24).IsRequired(); + entity.Property(item => item.LastError).HasMaxLength(2048); + entity.HasIndex(item => new { item.UserAccountId, item.PlatformDefinitionId, item.ConnectionName }).IsUnique(); + entity.HasOne(item => item.UserAccount) + .WithMany(user => user.PlatformConnections) + .HasForeignKey(item => item.UserAccountId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(item => item.PlatformDefinition) + .WithMany(platform => platform.UserConnections) + .HasForeignKey(item => item.PlatformDefinitionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("automation_nodes"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Name).HasMaxLength(128).IsRequired(); + entity.Property(item => item.Token).HasMaxLength(128).IsRequired(); + entity.Property(item => item.LastIp).HasMaxLength(64); + entity.HasIndex(item => item.Token).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("node_tasks"); + entity.HasKey(item => item.Id); + entity.Property(item => item.Status).HasConversion().HasMaxLength(16).IsRequired(); + entity.Property(item => item.CourseId).HasMaxLength(64).IsRequired(); + entity.Property(item => item.CourseName).HasMaxLength(256).IsRequired(); + entity.Property(item => item.PlatformUrl).HasMaxLength(1024).IsRequired(); + entity.Property(item => item.CurrentStep).HasMaxLength(512); + entity.Property(item => item.LastError).HasMaxLength(2048); + entity.HasOne(item => item.Node) + .WithMany() + .HasForeignKey(item => item.NodeId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("brush_tasks"); + entity.HasKey(item => item.Id); + entity.Property(item => item.PlatformSlug).HasMaxLength(64).IsRequired(); + entity.Property(item => item.CourseId).HasMaxLength(64).IsRequired(); + entity.Property(item => item.Status).HasMaxLength(16).IsRequired(); + entity.Property(item => item.ChaptersJson).HasMaxLength(32768); + entity.Property(item => item.EncryptedSessionData).HasMaxLength(16384); + entity.Property(item => item.CurrentChapterName).HasMaxLength(256); + entity.Property(item => item.CurrentSectionName).HasMaxLength(256); + entity.Property(item => item.CurrentVideoTitle).HasMaxLength(512); + entity.Property(item => item.LastError).HasMaxLength(2048); + entity.HasIndex(item => new { item.UserId, item.Status }); + }); + } +} diff --git a/backend/src/UoocProgress.Api/Models/ContractMappings.cs b/backend/src/UoocProgress.Api/Models/ContractMappings.cs new file mode 100644 index 0000000..d24d09a --- /dev/null +++ b/backend/src/UoocProgress.Api/Models/ContractMappings.cs @@ -0,0 +1,222 @@ +using System.Text.Json; + +namespace UoocProgress.Api.Models; + +public static class ContractMappings +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static AuthUserDto ToDto(this UserAccount user) => + new( + user.Id, + user.Username, + user.DisplayName, + EnumValueCodec.ToApiValue(user.Role), + EnumValueCodec.ToApiValue(user.Status), + user.CreatedAt, + user.LastLoginAt); + + public static InviteCodeDto ToDto(this InviteCodeRecord invite, string createdByDisplayName) => + new( + invite.Id, + invite.Code, + EnumValueCodec.ToApiValue(invite.Status), + invite.MaxUses, + invite.UsedCount, + invite.ExpiresAt, + invite.CreatedAt, + createdByDisplayName); + + public static SystemSettingDto ToDto(this SystemSettingRecord settings) => + new( + settings.SystemName, + EnumValueCodec.ToApiValue(settings.RegistrationMode), + settings.AllowMockFallback, + settings.BrowserChallengeTimeoutSeconds, + settings.ConnectionEncryptionVersion, + settings.DefaultPlatformVisibility, + settings.RequireEmailVerification, + settings.SmtpHost, + settings.SmtpPort, + settings.SmtpUseSsl, + settings.SmtpUsername, + !string.IsNullOrEmpty(settings.SmtpPassword), + settings.SmtpFromEmail, + settings.UpdatedAt); + + public static PlatformSummaryDto ToSummaryDto(this PlatformDefinition platform) => + new( + platform.Id, + platform.Slug, + platform.DisplayName, + platform.Description, + EnumValueCodec.ToApiValue(platform.Status), + platform.EnableBrowserChallenge); + + public static PlatformFieldDefinitionDto ToDto(this PlatformFieldDefinition field) => + new( + field.Id, + EnumValueCodec.ToApiValue(field.Scope), + field.Key, + field.Label, + EnumValueCodec.ToApiValue(field.Type), + field.IsRequired, + field.DisplayOrder, + field.Placeholder, + field.HelpText, + field.DefaultValue, + field.IsSensitive, + DeserializeOptions(field.OptionsJson)); + + public static PlatformWorkflowStepDto ToDto(this PlatformWorkflowStep step) => + new( + step.Id, + EnumValueCodec.ToApiValue(step.Scope), + step.StepKey, + step.DisplayName, + step.DisplayOrder, + EnumValueCodec.ToApiValue(step.StepType), + step.HttpMethod, + step.UrlTemplate, + step.QueryTemplateJson, + step.HeadersTemplateJson, + step.BodyTemplateJson, + step.ContentType, + step.SuccessPath, + step.SuccessExpectedValue, + step.PlatformUserLabelExpression, + DeserializeCookieMappings(step.OutputCookiesJson), + DeserializeVariableMappings(step.OutputVariablesJson), + DeserializeObject(step.CourseOptionMappingJson), + DeserializeObject(step.CatalogMappingJson), + DeserializeObject(step.UnitMappingJson), + step.BrowserSuccessUrlContains, + step.BrowserSuccessCookieName, + step.BrowserWaitForSelector, + step.BrowserTimeoutSeconds, + step.BrowserAutomationJson, + step.IsEnabled); + + public static PlatformDefinitionDto ToDto(this PlatformDefinition platform) + { + var loginFields = platform.FieldDefinitions + .Where(item => item.Scope == PlatformFieldScope.Login) + .OrderBy(item => item.DisplayOrder) + .Select(item => item.ToDto()) + .ToList(); + + var courseFields = platform.FieldDefinitions + .Where(item => item.Scope == PlatformFieldScope.CourseQuery) + .OrderBy(item => item.DisplayOrder) + .Select(item => item.ToDto()) + .ToList(); + + var steps = platform.WorkflowSteps.OrderBy(item => item.DisplayOrder).ToList(); + + return new PlatformDefinitionDto( + platform.Id, + platform.Slug, + platform.DisplayName, + platform.Description, + EnumValueCodec.ToApiValue(platform.Status), + platform.EnableBrowserChallenge, + platform.CourseQueryStepKey, + platform.SupportsCatalog, + platform.SupportsUnits, + platform.SupportsProgress, + platform.ChallengeTimeoutSeconds, + loginFields, + courseFields, + steps.Where(item => item.Scope == PlatformWorkflowScope.Login).Select(item => item.ToDto()).ToList(), + steps.Where(item => item.Scope == PlatformWorkflowScope.CourseQuery).Select(item => item.ToDto()).ToList(), + steps.Where(item => item.Scope == PlatformWorkflowScope.Catalog).Select(item => item.ToDto()).ToList(), + steps.Where(item => item.Scope == PlatformWorkflowScope.Units).Select(item => item.ToDto()).ToList(), + steps.Where(item => item.Scope == PlatformWorkflowScope.Progress).Select(item => item.ToDto()).ToList()); + } + + public static PlatformConnectionDto ToDto(this UserPlatformConnection connection) => + new( + connection.Id, + connection.PlatformDefinitionId, + connection.PlatformDefinition?.DisplayName ?? string.Empty, + connection.PlatformDefinition?.Slug ?? string.Empty, + connection.ConnectionName, + connection.PlatformUserLabel, + EnumValueCodec.ToApiValue(connection.Status), + connection.IsActive, + !string.IsNullOrWhiteSpace(connection.EncryptedFieldValues), + connection.Status == PlatformConnectionStatus.ChallengePending, + connection.CreatedAt, + connection.UpdatedAt, + connection.LastValidatedAt, + connection.LastSuccessfulLoginAt, + connection.LastError); + + private static IReadOnlyList DeserializeOptions(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch + { + return []; + } + } + + private static IReadOnlyList DeserializeCookieMappings(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch + { + return []; + } + } + + private static IReadOnlyList DeserializeVariableMappings(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch + { + return []; + } + } + + private static T? DeserializeObject(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return default; + } + } +} diff --git a/backend/src/UoocProgress.Api/Models/Contracts.cs b/backend/src/UoocProgress.Api/Models/Contracts.cs new file mode 100644 index 0000000..be9aee4 --- /dev/null +++ b/backend/src/UoocProgress.Api/Models/Contracts.cs @@ -0,0 +1,429 @@ +namespace UoocProgress.Api.Models; + +public sealed record PublicAuthConfigResponse( + string RegistrationMode, + string SystemName, + bool RequireEmailVerification); + +public sealed record RegisterRequest( + string Username, + string DisplayName, + string Password, + string? InviteCode, + string? Email, + string? EmailCode); + +public sealed record SendEmailCodeRequest(string Email); + +public sealed record LoginRequest( + string Username, + string Password); + +public sealed record ChangePasswordRequest( + string CurrentPassword, + string NewPassword); + +public sealed record AuthUserDto( + long Id, + string Username, + string DisplayName, + string Role, + string Status, + DateTimeOffset CreatedAt, + DateTimeOffset? LastLoginAt); + +public sealed record AuthTokenResponse( + string AccessToken, + DateTimeOffset ExpiresAt, + AuthUserDto User); + +public sealed record UpdateUserRequest( + string? DisplayName, + string? Role, + string? Status); + +public sealed record InviteCodeDto( + long Id, + string Code, + string Status, + int MaxUses, + int UsedCount, + DateTimeOffset? ExpiresAt, + DateTimeOffset CreatedAt, + string CreatedByDisplayName); + +public sealed record CreateInviteCodeRequest( + string? Code, + int MaxUses, + DateTimeOffset? ExpiresAt); + +public sealed record UpdateInviteCodeRequest(string Status); + +public sealed record SystemSettingDto( + string SystemName, + string RegistrationMode, + bool AllowMockFallback, + int BrowserChallengeTimeoutSeconds, + int ConnectionEncryptionVersion, + string DefaultPlatformVisibility, + bool RequireEmailVerification, + string? SmtpHost, + int SmtpPort, + bool SmtpUseSsl, + string? SmtpUsername, + bool HasSmtpPassword, + string? SmtpFromEmail, + DateTimeOffset UpdatedAt); + +public sealed record UpdateSystemSettingRequest( + string SystemName, + string RegistrationMode, + bool AllowMockFallback, + int BrowserChallengeTimeoutSeconds, + int ConnectionEncryptionVersion, + string DefaultPlatformVisibility, + bool RequireEmailVerification, + string? SmtpHost, + int SmtpPort, + bool SmtpUseSsl, + string? SmtpUsername, + string? SmtpPassword, + string? SmtpFromEmail); + +public sealed record SelectOptionDto(string Label, string Value); + +public sealed record PlatformFieldDefinitionDto( + long Id, + string Scope, + string Key, + string Label, + string Type, + bool IsRequired, + int DisplayOrder, + string? Placeholder, + string? HelpText, + string? DefaultValue, + bool IsSensitive, + IReadOnlyList Options); + +public sealed record PlatformCookieMappingDto(string Name, string Expression); + +public sealed record PlatformOutputVariableDto(string Key, string Expression); + +public sealed record CourseOptionMappingDto( + string ItemsPath, + string LabelPath, + string ValuePath); + +public sealed record CatalogMappingDto( + string ChaptersPath, + string ChapterIdPath, + string ChapterNumberPath, + string ChapterNamePath, + string ChapterFinishedPath, + string ChapterLearningPath, + string SectionsPath, + string SectionIdPath, + string SectionNumberPath, + string SectionNamePath, + string SectionFinishedPath, + string SectionLearningPath, + string SectionTaskIdPath); + +public sealed record UnitMappingDto( + string ItemsPath, + string ItemIdPath, + string ItemTitlePath, + string ItemTypePath, + string ItemFinishedPath, + string VideoSourcePath, + string VideoSourceNamePath, + string VideoPositionPath, + string VideoLengthPath, + string DocumentCountPath); + +public sealed record PlatformWorkflowStepDto( + long Id, + string Scope, + string StepKey, + string DisplayName, + int DisplayOrder, + string StepType, + string HttpMethod, + string? UrlTemplate, + string? QueryTemplateJson, + string? HeadersTemplateJson, + string? BodyTemplateJson, + string? ContentType, + string? SuccessPath, + string? SuccessExpectedValue, + string? PlatformUserLabelExpression, + IReadOnlyList OutputCookies, + IReadOnlyList OutputVariables, + CourseOptionMappingDto? CourseOptionMapping, + CatalogMappingDto? CatalogMapping, + UnitMappingDto? UnitMapping, + string? BrowserSuccessUrlContains, + string? BrowserSuccessCookieName, + string? BrowserWaitForSelector, + int? BrowserTimeoutSeconds, + string? BrowserAutomationJson, + bool IsEnabled); + +public sealed record PlatformSummaryDto( + long Id, + string Slug, + string DisplayName, + string Description, + string Status, + bool EnableBrowserChallenge); + +public sealed record PlatformDefinitionDto( + long Id, + string Slug, + string DisplayName, + string Description, + string Status, + bool EnableBrowserChallenge, + string? CourseQueryStepKey, + bool SupportsCatalog, + bool SupportsUnits, + bool SupportsProgress, + int ChallengeTimeoutSeconds, + IReadOnlyList LoginFields, + IReadOnlyList CourseQueryFields, + IReadOnlyList LoginSteps, + IReadOnlyList CourseQuerySteps, + IReadOnlyList CatalogSteps, + IReadOnlyList UnitSteps, + IReadOnlyList ProgressSteps); + +public sealed record PlatformSchemaDto( + long PlatformId, + string PlatformName, + string Scope, + IReadOnlyList Fields); + +public sealed record UpsertPlatformFieldDefinitionRequest( + long? Id, + string Scope, + string Key, + string Label, + string Type, + bool IsRequired, + int DisplayOrder, + string? Placeholder, + string? HelpText, + string? DefaultValue, + bool IsSensitive, + IReadOnlyList Options); + +public sealed record UpsertPlatformWorkflowStepRequest( + long? Id, + string Scope, + string StepKey, + string DisplayName, + int DisplayOrder, + string StepType, + string HttpMethod, + string? UrlTemplate, + string? QueryTemplateJson, + string? HeadersTemplateJson, + string? BodyTemplateJson, + string? ContentType, + string? SuccessPath, + string? SuccessExpectedValue, + string? PlatformUserLabelExpression, + IReadOnlyList OutputCookies, + IReadOnlyList OutputVariables, + CourseOptionMappingDto? CourseOptionMapping, + CatalogMappingDto? CatalogMapping, + UnitMappingDto? UnitMapping, + string? BrowserSuccessUrlContains, + string? BrowserSuccessCookieName, + string? BrowserWaitForSelector, + int? BrowserTimeoutSeconds, + string? BrowserAutomationJson, + bool IsEnabled); + +public sealed record SavePlatformDefinitionRequest( + string Slug, + string DisplayName, + string Description, + string Status, + bool EnableBrowserChallenge, + string? CourseQueryStepKey, + bool SupportsCatalog, + bool SupportsUnits, + bool SupportsProgress, + int ChallengeTimeoutSeconds, + IReadOnlyList Fields, + IReadOnlyList Steps); + +public sealed record PlatformStatusPatchRequest(string Status); + +public sealed record PlatformConnectionDto( + long Id, + long PlatformId, + string PlatformName, + string PlatformSlug, + string ConnectionName, + string? PlatformUserLabel, + string Status, + bool IsActive, + bool HasStoredCredentials, + bool HasChallengePending, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? LastValidatedAt, + DateTimeOffset? LastSuccessfulLoginAt, + string? LastError); + +public sealed record PlatformLoginStartRequest( + long PlatformId, + string? ConnectionName, + IReadOnlyDictionary Fields); + +public sealed record PlatformReloginRequest( + IReadOnlyDictionary Fields); + +public sealed record UoocLoginRequest( + long PlatformId, + string? ConnectionName, + string Account, + string Password, + string CaptchaVerifyParam); + +public sealed record PlatformLoginStartResponse( + string Status, + string Message, + PlatformConnectionDto? Connection, + string? ChallengeSessionId, + string? ChallengeUrl); + +public sealed record UoocLoginResponse( + string Status, + string Message, + PlatformConnectionDto? Connection); + +public sealed record ZhihuishuLoginRequest( + long PlatformId, + string? ConnectionName, + string Account, + string Password, + string CaptchaValidate); + +public sealed record ZhihuishuLoginResponse( + string Status, + string Message, + PlatformConnectionDto? Connection); + +public sealed record ChallengeSessionDto( + string Id, + string Status, + string Message, + string? ChallengeUrl, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + DateTimeOffset? CompletedAt); + +public sealed record CourseOptionDto( + string Value, + string Label); + +public sealed record PlatformCourseQueryRequest( + IReadOnlyDictionary Fields); + +public sealed record CourseOptionsResponse( + long ConnectionId, + string PlatformName, + IReadOnlyList Items, + DateTimeOffset QueriedAt, + string? Message); + +public sealed record CatalogSectionDto( + string Id, + string Number, + string Name, + bool Finished, + bool Learning, + string TaskId); + +public sealed record CatalogChapterDto( + string Id, + string Number, + string Name, + bool Finished, + bool Learning, + IReadOnlyList Sections); + +public sealed record CatalogResponse( + string CourseId, + IReadOnlyList Chapters, + bool Mock, + string Source, + string? Message); + +public sealed record VideoSourceDto(string Source, string SourceName); + +public sealed record UnitItemDto( + string Id, + string Title, + string Type, + bool Finished, + bool HasVideo, + double VideoPosition, + double? VideoLength, + string? PrimarySourceName, + string? PrimarySourceUrl, + int DocumentCount, + IReadOnlyList VideoSources, + string CatalogId); + +public sealed record UnitsResponse( + string CourseId, + string ChapterId, + string SectionId, + IReadOnlyList Items, + bool Mock, + string Source, + string? Message); + +public sealed record ProgressSummaryDto( + int TotalSections, + int CompletedSections, + int InProgressSections, + int TotalResources, + int CompletedResources, + double SectionCompletionRate, + double ResourceCompletionRate); + +public sealed record SectionProgressDto( + string Id, + string Number, + string Name, + bool Finished, + bool Learning, + string State, + int ResourceCount, + int CompletedResourceCount, + IReadOnlyList Resources); + +public sealed record ChapterProgressDto( + string Id, + string Number, + string Name, + bool Finished, + int CompletedSections, + int TotalSections, + IReadOnlyList Sections); + +public sealed record CourseProgressResponse( + string CourseId, + string CourseName, + ProgressSummaryDto Summary, + IReadOnlyList Chapters, + DateTimeOffset RefreshedAt, + bool Mock, + string Source, + string? Message); diff --git a/backend/src/UoocProgress.Api/Models/Entities.cs b/backend/src/UoocProgress.Api/Models/Entities.cs new file mode 100644 index 0000000..7ce3443 --- /dev/null +++ b/backend/src/UoocProgress.Api/Models/Entities.cs @@ -0,0 +1,374 @@ +namespace UoocProgress.Api.Models; + +public enum UserRole +{ + User = 1, + Admin = 2 +} + +public enum UserStatus +{ + Active = 1, + Disabled = 2 +} + +public enum InviteCodeStatus +{ + Active = 1, + Disabled = 2 +} + +public enum RegistrationMode +{ + Open = 1, + InviteOnly = 2 +} + +public enum PlatformStatus +{ + Draft = 1, + Active = 2, + Disabled = 3 +} + +public enum PlatformFieldScope +{ + Login = 1, + CourseQuery = 2 +} + +public enum PlatformFieldType +{ + Text = 1, + Password = 2, + Number = 3, + Select = 4, + Textarea = 5, + CaptchaText = 6, + SmsCode = 7, + EmailCode = 8, + Hidden = 9 +} + +public enum PlatformWorkflowScope +{ + Login = 1, + CourseQuery = 2, + Catalog = 3, + Units = 4, + Progress = 5 +} + +public enum PlatformWorkflowStepType +{ + HttpRequest = 1, + SessionPassthrough = 2, + BrowserChallenge = 3 +} + +public enum PlatformConnectionStatus +{ + Pending = 1, + Connected = 2, + ChallengePending = 3, + Failed = 4, + Disabled = 5 +} + +public sealed class UserAccount +{ + public long Id { get; set; } + + public string Username { get; set; } = string.Empty; + + public string UsernameNormalized { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public string? Email { get; set; } + + public string PasswordHash { get; set; } = string.Empty; + + public UserRole Role { get; set; } = UserRole.User; + + public UserStatus Status { get; set; } = UserStatus.Active; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? LastLoginAt { get; set; } + + public ICollection CreatedInviteCodes { get; set; } = []; + + public ICollection PlatformConnections { get; set; } = []; +} + +public sealed class InviteCodeRecord +{ + public long Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string CodeNormalized { get; set; } = string.Empty; + + public InviteCodeStatus Status { get; set; } = InviteCodeStatus.Active; + + public int MaxUses { get; set; } = 1; + + public int UsedCount { get; set; } + + public DateTimeOffset? ExpiresAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public long CreatedByUserId { get; set; } + + public UserAccount? CreatedByUser { get; set; } +} + +public sealed class SystemSettingRecord +{ + public long Id { get; set; } = 1; + + public string SystemName { get; set; } = "UOOC Progress"; + + public RegistrationMode RegistrationMode { get; set; } = RegistrationMode.Open; + + public bool AllowMockFallback { get; set; } = true; + + public int BrowserChallengeTimeoutSeconds { get; set; } = 600; + + public int ConnectionEncryptionVersion { get; set; } = 1; + + public string DefaultPlatformVisibility { get; set; } = "all_active"; + + public bool RequireEmailVerification { get; set; } + + public string? SmtpHost { get; set; } + + public int SmtpPort { get; set; } = 587; + + public bool SmtpUseSsl { get; set; } = true; + + public string? SmtpUsername { get; set; } + + public string? SmtpPassword { get; set; } + + public string? SmtpFromEmail { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlatformDefinition +{ + public long Id { get; set; } + + public string Slug { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public PlatformStatus Status { get; set; } = PlatformStatus.Draft; + + public bool EnableBrowserChallenge { get; set; } + + public string? CourseQueryStepKey { get; set; } + + public bool SupportsCatalog { get; set; } = true; + + public bool SupportsUnits { get; set; } = true; + + public bool SupportsProgress { get; set; } = true; + + public int ChallengeTimeoutSeconds { get; set; } = 600; + + public ICollection FieldDefinitions { get; set; } = []; + + public ICollection WorkflowSteps { get; set; } = []; + + public ICollection UserConnections { get; set; } = []; +} + +public sealed class PlatformFieldDefinition +{ + public long Id { get; set; } + + public long PlatformDefinitionId { get; set; } + + public PlatformDefinition? PlatformDefinition { get; set; } + + public PlatformFieldScope Scope { get; set; } = PlatformFieldScope.Login; + + public string Key { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public PlatformFieldType Type { get; set; } = PlatformFieldType.Text; + + public bool IsRequired { get; set; } = true; + + public int DisplayOrder { get; set; } + + public string? Placeholder { get; set; } + + public string? HelpText { get; set; } + + public string? DefaultValue { get; set; } + + public bool IsSensitive { get; set; } + + public string? OptionsJson { get; set; } +} + +public sealed class PlatformWorkflowStep +{ + public long Id { get; set; } + + public long PlatformDefinitionId { get; set; } + + public PlatformDefinition? PlatformDefinition { get; set; } + + public PlatformWorkflowScope Scope { get; set; } = PlatformWorkflowScope.Login; + + public string StepKey { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public int DisplayOrder { get; set; } + + public PlatformWorkflowStepType StepType { get; set; } = PlatformWorkflowStepType.HttpRequest; + + public string HttpMethod { get; set; } = "GET"; + + public string? UrlTemplate { get; set; } + + public string? QueryTemplateJson { get; set; } + + public string? HeadersTemplateJson { get; set; } + + public string? BodyTemplateJson { get; set; } + + public string? ContentType { get; set; } + + public string? SuccessPath { get; set; } + + public string? SuccessExpectedValue { get; set; } + + public string? PlatformUserLabelExpression { get; set; } + + public string? OutputCookiesJson { get; set; } + + public string? OutputVariablesJson { get; set; } + + public string? CourseOptionMappingJson { get; set; } + + public string? CatalogMappingJson { get; set; } + + public string? UnitMappingJson { get; set; } + + public string? BrowserSuccessUrlContains { get; set; } + + public string? BrowserSuccessCookieName { get; set; } + + public string? BrowserWaitForSelector { get; set; } + + public int? BrowserTimeoutSeconds { get; set; } + + /// + /// JSON array of browser automation actions for the agent to execute. + /// [{action:"navigate"|"click"|"wait_selector"|"wait_seconds"|"scroll"|"fill", ...}] + /// + public string? BrowserAutomationJson { get; set; } + + public bool IsEnabled { get; set; } = true; +} + +public sealed class UserPlatformConnection +{ + public long Id { get; set; } + + public long UserAccountId { get; set; } + + public UserAccount? UserAccount { get; set; } + + public long PlatformDefinitionId { get; set; } + + public PlatformDefinition? PlatformDefinition { get; set; } + + public string ConnectionName { get; set; } = string.Empty; + + public string? PlatformUserLabel { get; set; } + + public PlatformConnectionStatus Status { get; set; } = PlatformConnectionStatus.Pending; + + public bool IsActive { get; set; } + + public string? EncryptedFieldValues { get; set; } + + public string? EncryptedSessionData { get; set; } + + public DateTimeOffset? LastValidatedAt { get; set; } + + public DateTimeOffset? LastSuccessfulLoginAt { get; set; } + + public string? LastError { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public enum NodeTaskStatus { Pending = 1, Running = 2, Completed = 3, Failed = 4 } + +public sealed class AutomationNode +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + public string? LastIp { get; set; } + public DateTimeOffset LastHeartbeat { get; set; } = DateTimeOffset.UtcNow; + public bool IsOnline { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class NodeTask +{ + public long Id { get; set; } + public long? NodeId { get; set; } + public AutomationNode? Node { get; set; } + public long UserId { get; set; } + public string CourseId { get; set; } = string.Empty; + public string CourseName { get; set; } = string.Empty; + public string PlatformUrl { get; set; } = string.Empty; + public string TaskDataJson { get; set; } = string.Empty; // JSON: list of { chapterName, sections: [{sectionName, urls:["..."]}] } + public NodeTaskStatus Status { get; set; } = NodeTaskStatus.Pending; + public int TotalSteps { get; set; } + public int CompletedSteps { get; set; } + public string? CurrentStep { get; set; } + public string? LastError { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class BrushTaskRecord +{ + public long Id { get; set; } + public long UserId { get; set; } + public string PlatformSlug { get; set; } = ""; + public string CourseId { get; set; } = ""; + public string Status { get; set; } = ""; + public string ChaptersJson { get; set; } = ""; + public string EncryptedSessionData { get; set; } = ""; + public int TotalVideos { get; set; } + public int CompletedVideos { get; set; } + public string CurrentChapterName { get; set; } = ""; + public string CurrentSectionName { get; set; } = ""; + public string CurrentVideoTitle { get; set; } = ""; + public double CurrentVideoPos { get; set; } + public double CurrentVideoLength { get; set; } + public int RetryCount { get; set; } + public string? LastError { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? FinishedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/backend/src/UoocProgress.Api/Models/EnumValueCodec.cs b/backend/src/UoocProgress.Api/Models/EnumValueCodec.cs new file mode 100644 index 0000000..4f0ff9a --- /dev/null +++ b/backend/src/UoocProgress.Api/Models/EnumValueCodec.cs @@ -0,0 +1,303 @@ +namespace UoocProgress.Api.Models; + +public static class EnumValueCodec +{ + public static string ToApiValue(UserRole value) => + value == UserRole.Admin ? "admin" : "user"; + + public static string ToApiValue(UserStatus value) => + value == UserStatus.Disabled ? "disabled" : "active"; + + public static string ToApiValue(InviteCodeStatus value) => + value == InviteCodeStatus.Disabled ? "disabled" : "active"; + + public static string ToApiValue(RegistrationMode value) => + value == RegistrationMode.InviteOnly ? "invite_only" : "open"; + + public static string ToApiValue(PlatformStatus value) => + value switch + { + PlatformStatus.Active => "active", + PlatformStatus.Disabled => "disabled", + _ => "draft" + }; + + public static string ToApiValue(PlatformFieldScope value) => + value == PlatformFieldScope.CourseQuery ? "course_query" : "login"; + + public static string ToApiValue(PlatformFieldType value) => + value switch + { + PlatformFieldType.Password => "password", + PlatformFieldType.Number => "number", + PlatformFieldType.Select => "select", + PlatformFieldType.Textarea => "textarea", + PlatformFieldType.CaptchaText => "captcha_text", + PlatformFieldType.SmsCode => "sms_code", + PlatformFieldType.EmailCode => "email_code", + PlatformFieldType.Hidden => "hidden", + _ => "text" + }; + + public static string ToApiValue(PlatformWorkflowScope value) => + value switch + { + PlatformWorkflowScope.CourseQuery => "course_query", + PlatformWorkflowScope.Catalog => "catalog", + PlatformWorkflowScope.Units => "units", + PlatformWorkflowScope.Progress => "progress", + _ => "login" + }; + + public static string ToApiValue(PlatformWorkflowStepType value) => + value switch + { + PlatformWorkflowStepType.SessionPassthrough => "session_passthrough", + PlatformWorkflowStepType.BrowserChallenge => "browser_challenge", + _ => "http_request" + }; + + public static string ToApiValue(PlatformConnectionStatus value) => + value switch + { + PlatformConnectionStatus.Connected => "connected", + PlatformConnectionStatus.ChallengePending => "challenge_pending", + PlatformConnectionStatus.Failed => "failed", + PlatformConnectionStatus.Disabled => "disabled", + _ => "pending" + }; + + public static bool TryParseUserRole(string? value, out UserRole result) + { + if (string.Equals(value, "admin", StringComparison.OrdinalIgnoreCase)) + { + result = UserRole.Admin; + return true; + } + + if (string.Equals(value, "user", StringComparison.OrdinalIgnoreCase)) + { + result = UserRole.User; + return true; + } + + result = default; + return false; + } + + public static bool TryParseUserStatus(string? value, out UserStatus result) + { + if (string.Equals(value, "disabled", StringComparison.OrdinalIgnoreCase)) + { + result = UserStatus.Disabled; + return true; + } + + if (string.Equals(value, "active", StringComparison.OrdinalIgnoreCase)) + { + result = UserStatus.Active; + return true; + } + + result = default; + return false; + } + + public static bool TryParseInviteCodeStatus(string? value, out InviteCodeStatus result) + { + if (string.Equals(value, "disabled", StringComparison.OrdinalIgnoreCase)) + { + result = InviteCodeStatus.Disabled; + return true; + } + + if (string.Equals(value, "active", StringComparison.OrdinalIgnoreCase)) + { + result = InviteCodeStatus.Active; + return true; + } + + result = default; + return false; + } + + public static bool TryParseRegistrationMode(string? value, out RegistrationMode result) + { + if (string.Equals(value, "invite_only", StringComparison.OrdinalIgnoreCase)) + { + result = RegistrationMode.InviteOnly; + return true; + } + + if (string.Equals(value, "open", StringComparison.OrdinalIgnoreCase)) + { + result = RegistrationMode.Open; + return true; + } + + result = default; + return false; + } + + public static bool TryParsePlatformStatus(string? value, out PlatformStatus result) + { + if (string.Equals(value, "active", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformStatus.Active; + return true; + } + + if (string.Equals(value, "disabled", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformStatus.Disabled; + return true; + } + + if (string.Equals(value, "draft", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformStatus.Draft; + return true; + } + + result = default; + return false; + } + + public static bool TryParsePlatformFieldScope(string? value, out PlatformFieldScope result) + { + if (string.Equals(value, "course_query", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldScope.CourseQuery; + return true; + } + + if (string.Equals(value, "login", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldScope.Login; + return true; + } + + result = default; + return false; + } + + public static bool TryParsePlatformFieldType(string? value, out PlatformFieldType result) + { + if (string.Equals(value, "password", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.Password; + return true; + } + + if (string.Equals(value, "number", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.Number; + return true; + } + + if (string.Equals(value, "select", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.Select; + return true; + } + + if (string.Equals(value, "textarea", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.Textarea; + return true; + } + + if (string.Equals(value, "captcha_text", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.CaptchaText; + return true; + } + + if (string.Equals(value, "sms_code", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.SmsCode; + return true; + } + + if (string.Equals(value, "email_code", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.EmailCode; + return true; + } + + if (string.Equals(value, "hidden", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.Hidden; + return true; + } + + if (string.Equals(value, "text", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformFieldType.Text; + return true; + } + + result = default; + return false; + } + + public static bool TryParsePlatformWorkflowScope(string? value, out PlatformWorkflowScope result) + { + if (string.Equals(value, "course_query", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowScope.CourseQuery; + return true; + } + + if (string.Equals(value, "catalog", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowScope.Catalog; + return true; + } + + if (string.Equals(value, "units", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowScope.Units; + return true; + } + + if (string.Equals(value, "progress", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowScope.Progress; + return true; + } + + if (string.Equals(value, "login", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowScope.Login; + return true; + } + + result = default; + return false; + } + + public static bool TryParsePlatformWorkflowStepType(string? value, out PlatformWorkflowStepType result) + { + if (string.Equals(value, "session_passthrough", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowStepType.SessionPassthrough; + return true; + } + + if (string.Equals(value, "browser_challenge", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowStepType.BrowserChallenge; + return true; + } + + if (string.Equals(value, "http_request", StringComparison.OrdinalIgnoreCase)) + { + result = PlatformWorkflowStepType.HttpRequest; + return true; + } + + result = default; + return false; + } +} diff --git a/backend/src/UoocProgress.Api/Models/GatewayResult.cs b/backend/src/UoocProgress.Api/Models/GatewayResult.cs new file mode 100644 index 0000000..c453d73 --- /dev/null +++ b/backend/src/UoocProgress.Api/Models/GatewayResult.cs @@ -0,0 +1,19 @@ +namespace UoocProgress.Api.Models; + +public sealed record GatewayResult( + bool IsSuccess, + bool IsUnauthorized, + bool IsMock, + string Source, + T? Data, + string? Message) +{ + public static GatewayResult Success(T data, bool isMock, string source, string? message = null) => + new(true, false, isMock, source, data, message); + + public static GatewayResult Failure(string message) => + new(false, false, false, "none", default, message); + + public static GatewayResult Unauthorized(string message) => + new(false, true, false, "none", default, message); +} diff --git a/backend/src/UoocProgress.Api/Options/BootstrapAdminOptions.cs b/backend/src/UoocProgress.Api/Options/BootstrapAdminOptions.cs new file mode 100644 index 0000000..1393948 --- /dev/null +++ b/backend/src/UoocProgress.Api/Options/BootstrapAdminOptions.cs @@ -0,0 +1,12 @@ +namespace UoocProgress.Api.Options; + +public sealed class BootstrapAdminOptions +{ + public const string SectionName = "BootstrapAdmin"; + + public string Username { get; init; } = "admin"; + + public string DisplayName { get; init; } = "系统管理员"; + + public string Password { get; init; } = "Admin123!"; +} diff --git a/backend/src/UoocProgress.Api/Options/JwtOptions.cs b/backend/src/UoocProgress.Api/Options/JwtOptions.cs new file mode 100644 index 0000000..bce8176 --- /dev/null +++ b/backend/src/UoocProgress.Api/Options/JwtOptions.cs @@ -0,0 +1,14 @@ +namespace UoocProgress.Api.Options; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = "UoocProgress"; + + public string Audience { get; init; } = "UoocProgressClient"; + + public string SigningKey { get; init; } = "please-change-this-signing-key"; + + public int ExpiresMinutes { get; init; } = 720; +} diff --git a/backend/src/UoocProgress.Api/Options/UoocOptions.cs b/backend/src/UoocProgress.Api/Options/UoocOptions.cs new file mode 100644 index 0000000..c21ffef --- /dev/null +++ b/backend/src/UoocProgress.Api/Options/UoocOptions.cs @@ -0,0 +1,10 @@ +namespace UoocProgress.Api.Options; + +public sealed class UoocOptions +{ + public const string SectionName = "Uooc"; + + public string BaseUrl { get; init; } = "https://www.uooconline.com"; + + public int TimeoutSeconds { get; init; } = 15; +} diff --git a/backend/src/UoocProgress.Api/Options/ZhihuishuOptions.cs b/backend/src/UoocProgress.Api/Options/ZhihuishuOptions.cs new file mode 100644 index 0000000..9a214c5 --- /dev/null +++ b/backend/src/UoocProgress.Api/Options/ZhihuishuOptions.cs @@ -0,0 +1,35 @@ +namespace UoocProgress.Api.Options; + +public sealed class ZhihuishuOptions +{ + public const string SectionName = "Zhihuishu"; + + /// 智慧树 passport 域名 + public string PassportBaseUrl { get; init; } = "https://passport.zhihuishu.com"; + + /// 智慧树 onlineservice-api 域名 (共享学分课 - 课程列表) + public string OnlineServiceBaseUrl { get; init; } = "https://onlineservice-api.zhihuishu.com"; + + /// 智慧树 studyservice-api 域名 (共享学分课 - 学习/视频) + public string StudyServiceBaseUrl { get; init; } = "https://studyservice-api.zhihuishu.com"; + + /// 智慧树 newbase 域名 (视频播放) + public string NewbaseUrl { get; init; } = "https://newbase.zhihuishu.com"; + + /// 智慧树 appcomm-user 域名 (认证检查) + public string AppcommUserBaseUrl { get; init; } = "https://appcomm-user.zhihuishu.com"; + + /// Hike 校内学分课 - hikeservice + public string HikeServiceBaseUrl { get; init; } = "https://hikeservice.zhihuishu.com"; + + /// Hike 校内学分课 - studyresources + public string StudyResourcesBaseUrl { get; init; } = "https://studyresources.zhihuishu.com"; + + /// Hike 校内学分课 - hike-teaching (提交学习记录) + public string HikeTeachingBaseUrl { get; init; } = "https://hike-teaching.zhihuishu.com"; + + /// CAS login 完成后的 service 参数 + public string CasServiceUrl { get; init; } = "https://onlineservice-api.zhihuishu.com/gateway/t/v1/student/course/share/queryShareCourseInfo"; + + public int TimeoutSeconds { get; init; } = 30; +} diff --git a/backend/src/UoocProgress.Api/Program.cs b/backend/src/UoocProgress.Api/Program.cs new file mode 100644 index 0000000..a31440a --- /dev/null +++ b/backend/src/UoocProgress.Api/Program.cs @@ -0,0 +1,150 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; +using UoocProgress.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection(UoocOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(ZhihuishuOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(BootstrapAdminOptions.SectionName)); + +var connectionString = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("ConnectionStrings:Default is required."); + +builder.Services.AddDbContext(options => +{ + options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); + + if (builder.Environment.IsDevelopment()) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + } +}); + +builder.Services.AddControllers(); +builder.Services.AddCors(options => +{ + options.AddPolicy( + "frontend", + policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); +}); + +builder.Services.AddHttpClient("platform-workflow", (serviceProvider, client) => +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.UserAgent.ParseAdd("UoocProgress/3.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); +}); + +builder.Services.AddDataProtection(); +builder.Services.AddScoped>(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions(); +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = signingKey, + ClockSkew = TimeSpan.FromMinutes(1) + }; + + options.Events = new JwtBearerEvents + { + OnChallenge = context => + { + context.Response.Headers["X-Auth-Error"] = "system"; + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("UserOrAdmin", policy => policy.RequireAuthenticatedUser()); + options.AddPolicy("AdminOnly", policy => policy.RequireRole(nameof(UserRole.Admin))); +}); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.InitializeAsync(); +} + +// Restore persisted brush tasks that were running before shutdown +app.Services.GetRequiredService().RestorePersistedTasks(); + +var frontendDistPath = Path.GetFullPath( + Path.Combine(builder.Environment.ContentRootPath, "..", "..", "..", "frontend", "dist")); + +if (Directory.Exists(frontendDistPath)) +{ + var fileProvider = new PhysicalFileProvider(frontendDistPath); + + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = fileProvider + }); + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = fileProvider + }); +} + +app.UseCors("frontend"); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +if (Directory.Exists(frontendDistPath)) +{ + app.MapFallback(async context => + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(Path.Combine(frontendDistPath, "index.html")); + }); +} + +app.Run(); diff --git a/backend/src/UoocProgress.Api/Properties/launchSettings.json b/backend/src/UoocProgress.Api/Properties/launchSettings.json new file mode 100644 index 0000000..57aaf0c --- /dev/null +++ b/backend/src/UoocProgress.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5088", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/UoocProgress.Api/Services/BrowserChallengeService.cs b/backend/src/UoocProgress.Api/Services/BrowserChallengeService.cs new file mode 100644 index 0000000..d4fa30e --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/BrowserChallengeService.cs @@ -0,0 +1,118 @@ +using Microsoft.Playwright; + +namespace UoocProgress.Api.Services; + +public sealed class BrowserChallengeService(ChallengeSessionService challengeSessionService) +{ + public void RunChallenge( + string challengeId, + string launchUrl, + string? successUrlContains, + string? successCookieName, + string? waitForSelector, + int timeoutSeconds, + Func, Task> onCompleted) + { + _ = Task.Run(async () => + { + try + { + using var playwright = await Playwright.CreateAsync(); + var browser = await LaunchBrowserAsync(playwright); + await using var browserContext = await browser.NewContextAsync(); + var page = await browserContext.NewPageAsync(); + await page.GotoAsync(launchUrl); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(Math.Max(timeoutSeconds, 30)); + while (DateTimeOffset.UtcNow < deadline) + { + var cookies = await browserContext.CookiesAsync(); + if (HasChallengeCompleted(page.Url, cookies, successUrlContains, successCookieName, waitForSelector, page)) + { + var cookieMap = cookies.ToDictionary(item => item.Name, item => item.Value, StringComparer.OrdinalIgnoreCase); + await browser.CloseAsync(); + await onCompleted(cookieMap); + challengeSessionService.MarkCompleted(challengeId, "浏览器验证已完成。"); + return; + } + + await Task.Delay(1500); + } + + challengeSessionService.MarkFailed(challengeId, "浏览器验证超时,请重新发起登录。"); + } + catch (Exception exception) + { + challengeSessionService.MarkFailed(challengeId, $"浏览器验证失败:{exception.Message}"); + } + }); + } + + private static async Task LaunchBrowserAsync(IPlaywright playwright) + { + try + { + return await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions + { + Channel = "msedge", + Headless = false + }); + } + catch + { + try + { + return await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions + { + Channel = "chrome", + Headless = false + }); + } + catch + { + return await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions + { + Headless = false + }); + } + } + } + + private static bool HasChallengeCompleted( + string currentUrl, + IReadOnlyList cookies, + string? successUrlContains, + string? successCookieName, + string? waitForSelector, + IPage page) + { + if (!string.IsNullOrWhiteSpace(successCookieName) + && cookies.Any(item => item.Name.Equals(successCookieName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(successUrlContains) + && currentUrl.Contains(successUrlContains, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(waitForSelector)) + { + try + { + return page.Locator(waitForSelector).CountAsync().GetAwaiter().GetResult() > 0; + } + catch + { + return false; + } + } + + return false; + } +} diff --git a/backend/src/UoocProgress.Api/Services/ChallengeSessionService.cs b/backend/src/UoocProgress.Api/Services/ChallengeSessionService.cs new file mode 100644 index 0000000..ba1ae23 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/ChallengeSessionService.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class ChallengeSessionService +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + + public ChallengeSessionDto Create(long userId, long connectionId, string message, string? challengeUrl, int timeoutSeconds) + { + var session = new ChallengeSessionState + { + Id = Guid.NewGuid().ToString("N"), + UserId = userId, + ConnectionId = connectionId, + Status = "pending", + Message = message, + ChallengeUrl = challengeUrl, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(30, timeoutSeconds)) + }; + + _sessions[session.Id] = session; + return session.ToDto(); + } + + public ChallengeSessionDto? Get(string challengeId, long userId) + { + if (!_sessions.TryGetValue(challengeId, out var session) || session.UserId != userId) + { + return null; + } + + if (session.Status == "pending" && session.ExpiresAt <= DateTimeOffset.UtcNow) + { + session.Status = "expired"; + session.Message = "验证会话已过期,请重新发起登录。"; + } + + return session.ToDto(); + } + + public void MarkCompleted(string challengeId, string message) + { + if (_sessions.TryGetValue(challengeId, out var session)) + { + session.Status = "completed"; + session.Message = message; + session.CompletedAt = DateTimeOffset.UtcNow; + } + } + + public void MarkFailed(string challengeId, string message) + { + if (_sessions.TryGetValue(challengeId, out var session)) + { + session.Status = "failed"; + session.Message = message; + session.CompletedAt = DateTimeOffset.UtcNow; + } + } + + private sealed class ChallengeSessionState + { + public string Id { get; set; } = string.Empty; + + public long UserId { get; set; } + + public long ConnectionId { get; set; } + + public string Status { get; set; } = "pending"; + + public string Message { get; set; } = string.Empty; + + public string? ChallengeUrl { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + + public DateTimeOffset? CompletedAt { get; set; } + + public ChallengeSessionDto ToDto() => + new(Id, Status, Message, ChallengeUrl, CreatedAt, ExpiresAt, CompletedAt); + } +} diff --git a/backend/src/UoocProgress.Api/Services/DatabaseInitializer.cs b/backend/src/UoocProgress.Api/Services/DatabaseInitializer.cs new file mode 100644 index 0000000..cdcbac5 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/DatabaseInitializer.cs @@ -0,0 +1,530 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; + +namespace UoocProgress.Api.Services; + +public sealed class DatabaseInitializer( + AppDbContext dbContext, + PasswordHasher passwordHasher, + IOptions adminOptions, + IOptions uoocOptions) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + await EnsureSchemaColumnsAsync(cancellationToken); + + var existingSettings = await dbContext.SystemSettings.FirstOrDefaultAsync(cancellationToken); + if (existingSettings is null) + { + dbContext.SystemSettings.Add( + new SystemSettingRecord + { + Id = 1, + RegistrationMode = RegistrationMode.Open, + AllowMockFallback = false, + BrowserChallengeTimeoutSeconds = 600, + ConnectionEncryptionVersion = 1, + DefaultPlatformVisibility = "all_active", + UpdatedAt = DateTimeOffset.UtcNow + }); + } + else if (existingSettings.AllowMockFallback) + { + existingSettings.AllowMockFallback = false; + } + + var bootstrapAdmin = adminOptions.Value; + if (!string.IsNullOrWhiteSpace(bootstrapAdmin.Username) + && !string.IsNullOrWhiteSpace(bootstrapAdmin.Password)) + { + var normalizedUsername = Normalize(bootstrapAdmin.Username); + var admin = await dbContext.Users.SingleOrDefaultAsync( + item => item.UsernameNormalized == normalizedUsername, + cancellationToken); + + if (admin is null) + { + admin = new UserAccount + { + Username = bootstrapAdmin.Username.Trim(), + UsernameNormalized = normalizedUsername, + DisplayName = string.IsNullOrWhiteSpace(bootstrapAdmin.DisplayName) + ? bootstrapAdmin.Username.Trim() + : bootstrapAdmin.DisplayName.Trim(), + Role = UserRole.Admin, + Status = UserStatus.Active, + CreatedAt = DateTimeOffset.UtcNow + }; + + admin.PasswordHash = passwordHasher.HashPassword(admin, bootstrapAdmin.Password.Trim()); + dbContext.Users.Add(admin); + } + } + + await EnsureSeedPlatformAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public static string Normalize(string value) => + value.Trim().ToUpperInvariant(); + + /// + /// Idempotently adds columns introduced after the initial EnsureCreated, since the + /// project does not use EF migrations. Each ALTER is wrapped so duplicate-column errors are ignored. + /// + private async Task EnsureSchemaColumnsAsync(CancellationToken cancellationToken) + { + var statements = new[] + { + "ALTER TABLE users ADD COLUMN Email varchar(256) NULL", + "ALTER TABLE system_settings ADD COLUMN SystemName varchar(128) NOT NULL DEFAULT 'UOOC Progress'", + "ALTER TABLE system_settings ADD COLUMN RequireEmailVerification tinyint(1) NOT NULL DEFAULT 0", + "ALTER TABLE system_settings ADD COLUMN SmtpHost varchar(256) NULL", + "ALTER TABLE system_settings ADD COLUMN SmtpPort int NOT NULL DEFAULT 587", + "ALTER TABLE system_settings ADD COLUMN SmtpUseSsl tinyint(1) NOT NULL DEFAULT 1", + "ALTER TABLE system_settings ADD COLUMN SmtpUsername varchar(256) NULL", + "ALTER TABLE system_settings ADD COLUMN SmtpPassword varchar(512) NULL", + "ALTER TABLE system_settings ADD COLUMN SmtpFromEmail varchar(256) NULL", + "ALTER TABLE platform_workflow_steps ADD COLUMN BrowserAutomationJson longtext NULL", + // Automation nodes tables (EnsureCreated won't add tables to existing DB) + "CREATE TABLE IF NOT EXISTS automation_nodes (Id bigint AUTO_INCREMENT PRIMARY KEY, Name varchar(128) NOT NULL, Token varchar(128) NOT NULL, LastIp varchar(64) NULL, LastHeartbeat datetime(6) NOT NULL, IsOnline tinyint(1) NOT NULL, CreatedAt datetime(6) NOT NULL, UNIQUE INDEX IX_automation_nodes_Token (Token))", + "CREATE TABLE IF NOT EXISTS node_tasks (Id bigint AUTO_INCREMENT PRIMARY KEY, NodeId bigint NULL, UserId bigint NOT NULL, CourseId varchar(64) NOT NULL, CourseName varchar(256) NOT NULL, PlatformUrl varchar(1024) NOT NULL, TaskDataJson longtext NOT NULL, Status varchar(16) NOT NULL, TotalSteps int NOT NULL, CompletedSteps int NOT NULL, CurrentStep varchar(512) NULL, LastError varchar(2048) NULL, CreatedAt datetime(6) NOT NULL, UpdatedAt datetime(6) NOT NULL, INDEX IX_node_tasks_NodeId (NodeId), FOREIGN KEY (NodeId) REFERENCES automation_nodes(Id) ON DELETE SET NULL)", + "CREATE TABLE IF NOT EXISTS brush_tasks (Id bigint AUTO_INCREMENT PRIMARY KEY, UserId bigint NOT NULL, PlatformSlug varchar(64) NOT NULL, CourseId varchar(64) NOT NULL, Status varchar(16) NOT NULL, ChaptersJson longtext NOT NULL, EncryptedSessionData longtext NOT NULL, TotalVideos int NOT NULL, CompletedVideos int NOT NULL, CurrentChapterName varchar(256) NOT NULL, CurrentSectionName varchar(256) NOT NULL, CurrentVideoTitle varchar(512) NOT NULL, CurrentVideoPos double NOT NULL, CurrentVideoLength double NOT NULL, RetryCount int NOT NULL, LastError varchar(2048) NULL, CreatedAt datetime(6) NOT NULL, FinishedAt datetime(6) NULL, UpdatedAt datetime(6) NOT NULL, INDEX IX_brush_tasks_UserId_Status (UserId, Status))", + "ALTER TABLE brush_tasks ADD COLUMN FinishedAt datetime(6) NULL", + }; + + foreach (var sql in statements) + { + try + { + await dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken); + } + catch + { + // Column already exists — ignore. + } + } + } + + private async Task EnsureSeedPlatformAsync(CancellationToken cancellationToken) + { + await EnsureUoocPlatformAsync(cancellationToken); + await EnsureZhihuishuPlatformAsync(cancellationToken); + } + + private async Task EnsureUoocPlatformAsync(CancellationToken cancellationToken) + { + var existing = await dbContext.PlatformDefinitions + .Include(item => item.FieldDefinitions) + .Include(item => item.WorkflowSteps) + .FirstOrDefaultAsync(item => item.Slug == "uooc", cancellationToken); + + // Update existing UOOC platform to latest field definitions & API mappings + if (existing is not null) + { + var hasAccountField = existing.FieldDefinitions + .Any(item => item.Scope == PlatformFieldScope.Login && item.Key == "account"); + + if (!hasAccountField) + { + var oldLoginFields = existing.FieldDefinitions + .Where(item => item.Scope == PlatformFieldScope.Login) + .ToList(); + + foreach (var field in oldLoginFields) + { + dbContext.PlatformFieldDefinitions.Remove(field); + } + + BuildUoocLoginFields(existing); + } + + // Always update API mappings to latest + var courseStep = existing.WorkflowSteps + .FirstOrDefault(s => s.Scope == PlatformWorkflowScope.CourseQuery && s.StepKey == "course-list"); + if (courseStep is not null) + { + courseStep.CourseOptionMappingJson = Serialize( + new CourseOptionMappingDto("$.data.data[]", "parent_name", "id")); + } + + var catalogStep = existing.WorkflowSteps + .FirstOrDefault(s => s.Scope == PlatformWorkflowScope.Catalog && s.StepKey == "catalog-list"); + if (catalogStep is not null) + { + catalogStep.CatalogMappingJson = Serialize( + new CatalogMappingDto("$.data[]", "id", "_n", "name", "finished", "learning", + "children[]", "id", "_n", "name", "finished", "learning", "task_id")); + } + + var unitStep = existing.WorkflowSteps + .FirstOrDefault(s => s.Scope == PlatformWorkflowScope.Units && s.StepKey == "unit-list"); + if (unitStep is not null) + { + unitStep.UnitMappingJson = Serialize( + new UnitMappingDto("$.data[]", "id", "title", "type", "finished", + "video_play_list[0].source", "video_play_list[0].source_name", + "video_pos", "_n", "document")); + } + + return; + } + + var platform = BuildUoocPlatform(); + dbContext.PlatformDefinitions.Add(platform); + } + + private void BuildUoocLoginFields(PlatformDefinition platform) + { + platform.FieldDefinitions.Add( + new PlatformFieldDefinition + { + Scope = PlatformFieldScope.Login, + Key = "account", + Label = "手机号", + Type = PlatformFieldType.Text, + IsRequired = true, + DisplayOrder = 1, + Placeholder = "请输入 UOOC 手机号", + HelpText = "输入 UOOC 平台绑定的手机号。", + IsSensitive = false + }); + + platform.FieldDefinitions.Add( + new PlatformFieldDefinition + { + Scope = PlatformFieldScope.Login, + Key = "password", + Label = "密码", + Type = PlatformFieldType.Password, + IsRequired = true, + DisplayOrder = 2, + Placeholder = "请输入 UOOC 密码", + HelpText = "输入 mock-session 可跳过验证直接使用演示数据。", + IsSensitive = true + }); + + platform.FieldDefinitions.Add( + new PlatformFieldDefinition + { + Scope = PlatformFieldScope.Login, + Key = "sessionToken", + Label = "会话令牌(可选)", + Type = PlatformFieldType.Hidden, + IsRequired = false, + DisplayOrder = 3, + DefaultValue = "", + IsSensitive = true + }); + } + + private PlatformDefinition BuildUoocPlatform() + { + var baseUrl = uoocOptions.Value.BaseUrl.TrimEnd('/'); + var platform = new PlatformDefinition + { + Slug = "uooc", + DisplayName = "UOOC 在线课程", + Description = "内置的 UOOC 平台模板。登录通过前端滑块验证码 + 后端代理 /user/login 完成。", + Status = PlatformStatus.Active, + EnableBrowserChallenge = false, + CourseQueryStepKey = "course-list", + SupportsCatalog = true, + SupportsUnits = true, + SupportsProgress = true, + ChallengeTimeoutSeconds = 600 + }; + + BuildUoocLoginFields(platform); + + platform.FieldDefinitions.Add( + new PlatformFieldDefinition + { + Scope = PlatformFieldScope.CourseQuery, + Key = "keyword", + Label = "课程关键词", + Type = PlatformFieldType.Text, + IsRequired = false, + DisplayOrder = 1, + Placeholder = "可选,用于筛选课程", + HelpText = "留空时读取第一页课程。" + }); + + platform.FieldDefinitions.Add( + new PlatformFieldDefinition + { + Scope = PlatformFieldScope.CourseQuery, + Key = "page", + Label = "页码", + Type = PlatformFieldType.Number, + IsRequired = false, + DisplayOrder = 2, + DefaultValue = "1", + Placeholder = "1" + }); + + platform.WorkflowSteps.Add( + new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.Login, + StepKey = "session-pass", + DisplayName = "会话令牌直传(mock 模式或已有令牌)", + DisplayOrder = 1, + StepType = PlatformWorkflowStepType.SessionPassthrough, + OutputCookiesJson = Serialize( + new[] + { + new PlatformCookieMappingDto("uooc_auth", "{{field.sessionToken}}") + }), + OutputVariablesJson = Serialize( + new[] + { + new PlatformOutputVariableDto("sessionToken", "{{field.sessionToken}}") + }), + PlatformUserLabelExpression = "{{field.account}}", + IsEnabled = true + }); + + platform.WorkflowSteps.Add( + new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.CourseQuery, + StepKey = "course-list", + DisplayName = "读取课程列表", + DisplayOrder = 10, + StepType = PlatformWorkflowStepType.HttpRequest, + HttpMethod = "GET", + UrlTemplate = $"{baseUrl}/home/course/list", + QueryTemplateJson = "{\"keyword\":\"{{field.keyword}}\",\"page\":\"{{field.page}}\",\"type\":\"learn\"}", + SuccessPath = "$.code", + SuccessExpectedValue = "1", + CourseOptionMappingJson = Serialize( + new CourseOptionMappingDto( + "$.data.data[]", + "parent_name", + "id")) + }); + + platform.WorkflowSteps.Add( + new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.Catalog, + StepKey = "catalog-list", + DisplayName = "读取章节目录", + DisplayOrder = 20, + StepType = PlatformWorkflowStepType.HttpRequest, + HttpMethod = "GET", + UrlTemplate = $"{baseUrl}/home/learn/getCatalogList", + QueryTemplateJson = "{\"cid\":\"{{context.courseId}}\",\"hidemsg_\":\"true\",\"show\":\"\"}", + SuccessPath = "$.code", + SuccessExpectedValue = "1", + CatalogMappingJson = Serialize( + new CatalogMappingDto( + "$.data[]", + "id", + "_n", + "name", + "finished", + "learning", + "children[]", + "id", + "_n", + "name", + "finished", + "learning", + "task_id")) + }); + + platform.WorkflowSteps.Add( + new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.Units, + StepKey = "unit-list", + DisplayName = "读取资源列表", + DisplayOrder = 30, + StepType = PlatformWorkflowStepType.HttpRequest, + HttpMethod = "GET", + UrlTemplate = $"{baseUrl}/home/learn/getUnitLearn", + QueryTemplateJson = + "{\"cid\":\"{{context.courseId}}\",\"chapter_id\":\"{{context.chapterId}}\",\"section_id\":\"{{context.sectionId}}\",\"catalog_id\":\"{{context.sectionId}}\",\"hidemsg_\":\"true\",\"show\":\"\"}", + SuccessPath = "$.code", + SuccessExpectedValue = "1", + UnitMappingJson = Serialize( + new UnitMappingDto( + "$.data[]", + "id", + "title", + "type", + "finished", + "video_play_list[0].source", + "video_play_list[0].source_name", + "video_pos", + "_n", + "document")) + }); + + return platform; + } + + // ── Zhihuishu (智慧树) Platform ──────────────────────── + + private async Task EnsureZhihuishuPlatformAsync(CancellationToken cancellationToken) + { + var existing = await dbContext.PlatformDefinitions + .Include(item => item.FieldDefinitions) + .Include(item => item.WorkflowSteps) + .FirstOrDefaultAsync(item => item.Slug == "zhihuishu", cancellationToken); + + if (existing is not null) + { + // Update existing zhihuishu platform + var hasAccountField = existing.FieldDefinitions + .Any(item => item.Scope == PlatformFieldScope.Login && item.Key == "account"); + + if (!hasAccountField) + { + var oldLoginFields = existing.FieldDefinitions + .Where(item => item.Scope == PlatformFieldScope.Login) + .ToList(); + foreach (var field in oldLoginFields) + dbContext.PlatformFieldDefinitions.Remove(field); + BuildZhihuishuLoginFields(existing); + } + + return; + } + + var platform = BuildZhihuishuPlatform(); + dbContext.PlatformDefinitions.Add(platform); + } + + private void BuildZhihuishuLoginFields(PlatformDefinition platform) + { + platform.FieldDefinitions.Add(new PlatformFieldDefinition + { + Scope = PlatformFieldScope.Login, + Key = "account", + Label = "手机号", + Type = PlatformFieldType.Text, + IsRequired = true, + DisplayOrder = 1, + Placeholder = "请输入智慧树绑定的手机号", + HelpText = "智慧树平台注册手机号。", + IsSensitive = false + }); + + platform.FieldDefinitions.Add(new PlatformFieldDefinition + { + Scope = PlatformFieldScope.Login, + Key = "password", + Label = "密码", + Type = PlatformFieldType.Password, + IsRequired = true, + DisplayOrder = 2, + Placeholder = "请输入智慧树密码", + HelpText = "密码通过加密传输至智慧树服务器。", + IsSensitive = true + }); + } + + private PlatformDefinition BuildZhihuishuPlatform() + { + var platform = new PlatformDefinition + { + Slug = "zhihuishu", + DisplayName = "智慧树", + Description = "智慧树(Zhihuishu)在线课程平台。支持共享学分课(Zhidao)和校内学分课(Hike)。登录通过网易易盾滑块验证码 + 后端 CAS 代理完成。", + Status = PlatformStatus.Active, + EnableBrowserChallenge = false, + CourseQueryStepKey = "course-list", + SupportsCatalog = true, + SupportsUnits = true, + SupportsProgress = true, + ChallengeTimeoutSeconds = 600 + }; + + BuildZhihuishuLoginFields(platform); + + // Course query field (optional filter) + platform.FieldDefinitions.Add(new PlatformFieldDefinition + { + Scope = PlatformFieldScope.CourseQuery, + Key = "page", + Label = "页码", + Type = PlatformFieldType.Number, + IsRequired = false, + DisplayOrder = 1, + DefaultValue = "1", + Placeholder = "1" + }); + + // Minimal workflow steps — actual API calls go through ZhihuishuApiService directly + platform.WorkflowSteps.Add(new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.Login, + StepKey = "zhihuishu-direct", + DisplayName = "智慧树直连登录(CAS + 滑块验证码)", + DisplayOrder = 1, + StepType = PlatformWorkflowStepType.SessionPassthrough, + PlatformUserLabelExpression = "{{field.account}}", + IsEnabled = true + }); + + platform.WorkflowSteps.Add(new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.CourseQuery, + StepKey = "course-list", + DisplayName = "读取课程列表(Zhidao AES 加密)", + DisplayOrder = 10, + StepType = PlatformWorkflowStepType.HttpRequest, + HttpMethod = "POST", + UrlTemplate = "https://onlineservice-api.zhihuishu.com/gateway/t/v1/student/course/share/queryShareCourseInfo", + CourseOptionMappingJson = Serialize( + new CourseOptionMappingDto("$.result.courseOpenDtos[]", "courseName", "secret")), + IsEnabled = true + }); + + platform.WorkflowSteps.Add(new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.Catalog, + StepKey = "catalog-list", + DisplayName = "读取章节目录(Zhidao videolist)", + DisplayOrder = 20, + StepType = PlatformWorkflowStepType.HttpRequest, + HttpMethod = "POST", + UrlTemplate = "https://studyservice-api.zhihuishu.com/gateway/t/v1/learning/videolist", + CatalogMappingJson = Serialize( + new CatalogMappingDto( + "$.data.videoChapterDtos[]", + "id", "_n", "name", "_n", "_n", + "videoLessons[]", + "id", "_n", "name", "_n", "_n", "_n")), + IsEnabled = true + }); + + platform.WorkflowSteps.Add(new PlatformWorkflowStep + { + Scope = PlatformWorkflowScope.Units, + StepKey = "unit-list", + DisplayName = "读取资源列表", + DisplayOrder = 30, + StepType = PlatformWorkflowStepType.HttpRequest, + HttpMethod = "GET", + UrlTemplate = "https://newbase.zhihuishu.com/video/initVideo", + IsEnabled = true + }); + + return platform; + } + + private static string Serialize(T value) => + JsonSerializer.Serialize(value, JsonOptions); +} diff --git a/backend/src/UoocProgress.Api/Services/EmailVerificationService.cs b/backend/src/UoocProgress.Api/Services/EmailVerificationService.cs new file mode 100644 index 0000000..3121676 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/EmailVerificationService.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Mail; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class EmailVerificationService(SystemSettingsService settingsService) +{ + private static readonly ConcurrentDictionary _codes = new(StringComparer.OrdinalIgnoreCase); + + private sealed record CodeEntry(string Code, DateTimeOffset ExpiresAt, DateTimeOffset LastSentAt); + + public async Task SendCodeAsync(string email, CancellationToken cancellationToken) + { + email = email.Trim(); + if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) + { + throw new InvalidOperationException("请输入有效的邮箱地址。"); + } + + var settings = await settingsService.GetEntityAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(settings.SmtpHost) || string.IsNullOrWhiteSpace(settings.SmtpFromEmail)) + { + throw new InvalidOperationException("邮件服务未配置,请联系管理员。"); + } + + // Rate limit: 60s between sends + if (_codes.TryGetValue(email, out var existing) + && (DateTimeOffset.UtcNow - existing.LastSentAt).TotalSeconds < 60) + { + throw new InvalidOperationException("验证码发送过于频繁,请稍后再试。"); + } + + var code = GenerateCode(); + _codes[email] = new CodeEntry(code, DateTimeOffset.UtcNow.AddMinutes(10), DateTimeOffset.UtcNow); + + await SendEmailAsync(settings, email, code, cancellationToken); + } + + public bool Verify(string email, string code) + { + email = email.Trim(); + if (!_codes.TryGetValue(email, out var entry)) + { + return false; + } + + if (entry.ExpiresAt < DateTimeOffset.UtcNow) + { + _codes.TryRemove(email, out _); + return false; + } + + if (!string.Equals(entry.Code, code?.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + _codes.TryRemove(email, out _); + return true; + } + + private static string GenerateCode() + { + return Random.Shared.Next(0, 1_000_000).ToString("D6"); + } + + private static async Task SendEmailAsync(SystemSettingRecord settings, string toEmail, string code, CancellationToken cancellationToken) + { + using var client = new SmtpClient(settings.SmtpHost, settings.SmtpPort) + { + EnableSsl = settings.SmtpUseSsl, + DeliveryMethod = SmtpDeliveryMethod.Network, + }; + + if (!string.IsNullOrWhiteSpace(settings.SmtpUsername)) + { + client.Credentials = new NetworkCredential(settings.SmtpUsername, settings.SmtpPassword ?? string.Empty); + } + + using var message = new MailMessage + { + From = new MailAddress(settings.SmtpFromEmail!, settings.SystemName), + Subject = $"【{settings.SystemName}】注册验证码", + Body = $"您的注册验证码是:{code}\n\n验证码 10 分钟内有效,请勿泄露给他人。", + IsBodyHtml = false, + }; + message.To.Add(toEmail); + + try + { + await client.SendMailAsync(message, cancellationToken); + } + catch (Exception ex) + { + throw new InvalidOperationException($"邮件发送失败:{ex.Message}"); + } + } +} diff --git a/backend/src/UoocProgress.Api/Services/JwtTokenService.cs b/backend/src/UoocProgress.Api/Services/JwtTokenService.cs new file mode 100644 index 0000000..3930793 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/JwtTokenService.cs @@ -0,0 +1,43 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; + +namespace UoocProgress.Api.Services; + +public sealed class JwtTokenService(IOptions options) +{ + private readonly JwtOptions _options = options.Value; + + public AuthTokenResponse Create(UserAccount user) + { + var now = DateTimeOffset.UtcNow; + var expiresAt = now.AddMinutes(Math.Max(5, _options.ExpiresMinutes)); + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: + [ + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Role, user.Role.ToString()), + new Claim("display_name", user.DisplayName) + ], + notBefore: now.UtcDateTime, + expires: expiresAt.UtcDateTime, + signingCredentials: credentials); + + return new AuthTokenResponse( + new JwtSecurityTokenHandler().WriteToken(token), + expiresAt, + user.ToDto()); + } +} diff --git a/backend/src/UoocProgress.Api/Services/MockUoocData.cs b/backend/src/UoocProgress.Api/Services/MockUoocData.cs new file mode 100644 index 0000000..1a1e4e9 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/MockUoocData.cs @@ -0,0 +1,191 @@ +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class MockUoocData +{ + public const string DemoSessionToken = "mock-session"; + + private readonly IReadOnlyDictionary _courses; + + public MockUoocData() + { + _courses = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["1915772341"] = new( + new CourseOptionDto("1915772341", "Java 语言程序设计"), + [ + new CatalogChapterDto( + "154368344", + "1", + "Java 入门", + false, + true, + [ + new CatalogSectionDto("1876955581", "1.1", "Java 语言概述", true, false, "0"), + new CatalogSectionDto("6874324", "1.4", "在 IDE 中调试 Java 程序", false, true, "0"), + new CatalogSectionDto("1580122818", "1.5", "第一章测验", false, false, "1137345134") + ]), + new CatalogChapterDto( + "1716894985", + "2", + "Java 数据类型", + false, + false, + [ + new CatalogSectionDto("451996495", "2.2", "Java 基本数据类型", false, false, "0"), + new CatalogSectionDto("1008442151", "2.5", "第二章测验", false, false, "565730115") + ]) + ], + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["1876955581"] = + [ + new UnitItemDto("150100001", "课程导学视频", "video", true, true, 623, 623, "高清", "https://example.com/mock/java-1-1.mp4", 0, [], ""), + new UnitItemDto("150100002", "课程说明文档", "document", true, false, 0, null, null, null, 1, [], "") + ], + ["6874324"] = + [ + new UnitItemDto("1502018861", "集成环境调试视频", "video", false, true, 642, 1005.4, "高清", "https://example.com/mock/java-1-4.mp4", 0, [], ""), + new UnitItemDto("1502018862", "环境配置讲义", "document", true, false, 0, null, null, null, 1, [], "") + ], + ["1580122818"] = [], + ["451996495"] = + [ + new UnitItemDto("150200001", "基本数据类型视频", "video", false, true, 0, 840, "标清", "https://example.com/mock/java-2-2.mp4", 0, [], "") + ], + ["1008442151"] = + [ + new UnitItemDto("150200002", "章节测验说明", "quiz", false, false, 0, null, null, null, 0, [], "") + ] + }), + ["2025001001"] = new( + new CourseOptionDto("2025001001", "Vue 3 组件化实战"), + [ + new CatalogChapterDto( + "301001", + "1", + "Vue 3 入门", + true, + false, + [ + new CatalogSectionDto("301101", "1.1", "Composition API 心智模型", true, false, "0"), + new CatalogSectionDto("301102", "1.2", "组件通信", true, false, "0") + ]), + new CatalogChapterDto( + "301002", + "2", + "实战模块", + false, + true, + [ + new CatalogSectionDto("301201", "2.1", "状态管理拆解", false, true, "0"), + new CatalogSectionDto("301202", "2.2", "路由守卫与权限", false, false, "0") + ]) + ], + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["301101"] = + [ + new UnitItemDto("30110101", "Composition API 讲解", "video", true, true, 780, 780, "高清", "https://example.com/mock/vue-1-1.mp4", 0, [], "") + ], + ["301102"] = + [ + new UnitItemDto("30110201", "Props 与 Emits", "video", true, true, 910, 910, "高清", "https://example.com/mock/vue-1-2.mp4", 0, [], ""), + new UnitItemDto("30110202", "通信示例代码", "document", true, false, 0, null, null, null, 2, [], "") + ], + ["301201"] = + [ + new UnitItemDto("30120101", "Pinia 状态拆解", "video", false, true, 356, 1040, "高清", "https://example.com/mock/vue-2-1.mp4", 0, [], ""), + new UnitItemDto("30120102", "实战任务清单", "document", false, false, 0, null, null, null, 1, [], "") + ], + ["301202"] = [] + }), + ["2025001002"] = new( + new CourseOptionDto("2025001002", "数据结构与算法基础"), + [ + new CatalogChapterDto( + "401001", + "1", + "线性表与栈队列", + false, + false, + [ + new CatalogSectionDto("401101", "1.1", "顺序表", false, false, "0"), + new CatalogSectionDto("401102", "1.2", "链表", false, false, "0") + ]), + new CatalogChapterDto( + "401002", + "2", + "树与图", + false, + false, + [ + new CatalogSectionDto("401201", "2.1", "树的遍历", false, false, "0"), + new CatalogSectionDto("401202", "2.2", "最短路径", false, false, "0") + ]) + ], + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["401101"] = + [ + new UnitItemDto("40110101", "顺序表概念", "video", false, true, 120, 960, "高清", "https://example.com/mock/algorithm-1-1.mp4", 0, [], "") + ], + ["401102"] = + [ + new UnitItemDto("40110201", "链表讲义", "document", false, false, 0, null, null, null, 1, [], "") + ], + ["401201"] = [], + ["401202"] = + [ + new UnitItemDto("40120201", "最短路径案例", "video", false, true, 0, 1120, "高清", "https://example.com/mock/algorithm-2-2.mp4", 0, [], "") + ] + }) + }; + } + + public bool IsDemoToken(string? sessionToken) => + string.Equals(sessionToken?.Trim(), DemoSessionToken, StringComparison.OrdinalIgnoreCase); + + public IReadOnlyList GetCourseOptions(string? keyword) => + _courses.Values + .Select(item => item.Course) + .Where(item => + string.IsNullOrWhiteSpace(keyword) + || item.Label.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + .OrderBy(item => item.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + public CatalogResponse GetCatalog(string courseId) + { + if (!_courses.TryGetValue(courseId, out var definition)) + { + return new CatalogResponse(courseId, [], true, "mock", "当前课程没有可用的演示章节数据。"); + } + + return new CatalogResponse(courseId, definition.Chapters, true, "mock", "章节目录来自内置 mock 数据。"); + } + + public UnitsResponse GetUnits(string courseId, string chapterId, string sectionId) + { + if (!_courses.TryGetValue(courseId, out var definition)) + { + return new UnitsResponse(courseId, chapterId, sectionId, [], true, "mock", "当前课程没有可用的演示资源数据。"); + } + + if (!definition.UnitsBySectionId.TryGetValue(sectionId, out var items)) + { + items = []; + } + + return new UnitsResponse(courseId, chapterId, sectionId, items, true, "mock", "资源列表来自内置 mock 数据。"); + } + + public string? TryGetCourseName(string courseId) => + _courses.TryGetValue(courseId, out var definition) ? definition.Course.Label : null; + + private sealed record MockCourseDefinition( + CourseOptionDto Course, + IReadOnlyList Chapters, + IReadOnlyDictionary> UnitsBySectionId); +} diff --git a/backend/src/UoocProgress.Api/Services/NodeService.cs b/backend/src/UoocProgress.Api/Services/NodeService.cs new file mode 100644 index 0000000..f80b336 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/NodeService.cs @@ -0,0 +1,186 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class NodeService(AppDbContext dbContext) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + // ── Node management ── + + public async Task RegisterAsync(string name, string token, string? ip) + { + var existing = await dbContext.AutomationNodes + .FirstOrDefaultAsync(n => n.Token == token); + + if (existing is not null) + { + existing.Name = name; + existing.LastIp = ip; + existing.LastHeartbeat = DateTimeOffset.UtcNow; + existing.IsOnline = true; + await dbContext.SaveChangesAsync(); + return existing; + } + + var node = new AutomationNode + { + Name = name, + Token = token, + LastIp = ip, + LastHeartbeat = DateTimeOffset.UtcNow, + IsOnline = true, + }; + dbContext.AutomationNodes.Add(node); + await dbContext.SaveChangesAsync(); + return node; + } + + public async Task HeartbeatAsync(long nodeId, string? ip) + { + var node = await dbContext.AutomationNodes.FindAsync(nodeId); + if (node is null) return; + node.LastHeartbeat = DateTimeOffset.UtcNow; + node.IsOnline = true; + if (ip is not null) node.LastIp = ip; + await dbContext.SaveChangesAsync(); + } + + public async Task> GetNodesAsync() + { + // Mark nodes offline if no heartbeat in 30s + var deadline = DateTimeOffset.UtcNow.AddSeconds(-30); + var stale = await dbContext.AutomationNodes + .Where(n => n.IsOnline && n.LastHeartbeat < deadline) + .ToListAsync(); + foreach (var n in stale) n.IsOnline = false; + if (stale.Count > 0) await dbContext.SaveChangesAsync(); + + return await dbContext.AutomationNodes.AsNoTracking() + .OrderBy(n => n.Name).ToListAsync(); + } + + public async Task DeleteNodeAsync(long nodeId) + { + var node = await dbContext.AutomationNodes.FindAsync(nodeId); + if (node is null) return; + + // Release assigned tasks + var tasks = await dbContext.NodeTasks + .Where(t => t.NodeId == nodeId && (t.Status == NodeTaskStatus.Pending || t.Status == NodeTaskStatus.Running)) + .ToListAsync(); + foreach (var t in tasks) { t.NodeId = null; t.Status = NodeTaskStatus.Pending; } + + dbContext.AutomationNodes.Remove(node); + await dbContext.SaveChangesAsync(); + } + + // ── Task management ── + + public async Task EnqueueAsync(long userId, string courseId, string courseName, + string platformUrl, string taskDataJson) + { + // Count total steps from JSON: chapters → sections + var chapters = JsonSerializer.Deserialize>(taskDataJson, JsonOptions) ?? []; + var totalSteps = chapters.Sum(c => c.Sections.Count); + + var task = new NodeTask + { + UserId = userId, + CourseId = courseId, + CourseName = courseName, + PlatformUrl = platformUrl, + TaskDataJson = taskDataJson, + Status = NodeTaskStatus.Pending, + TotalSteps = totalSteps, + CompletedSteps = 0, + }; + dbContext.NodeTasks.Add(task); + await dbContext.SaveChangesAsync(); + return task; + } + + public async Task PollAsync(long nodeId) + { + // Try to claim a pending task + var task = await dbContext.NodeTasks + .Where(t => t.Status == NodeTaskStatus.Pending && (t.NodeId == null || t.NodeId == nodeId)) + .OrderBy(t => t.CreatedAt) + .FirstOrDefaultAsync(); + + if (task is null) return null; + + task.NodeId = nodeId; + task.Status = NodeTaskStatus.Running; + task.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(); + return task; + } + + public async Task UpdateProgressAsync(long taskId, int completedSteps, + string? currentStep, string? lastError) + { + var task = await dbContext.NodeTasks.FindAsync(taskId); + if (task is null) return; + + task.CompletedSteps = completedSteps; + task.CurrentStep = currentStep; + task.LastError = lastError; + task.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(); + } + + public async Task CompleteAsync(long taskId) + { + var task = await dbContext.NodeTasks.FindAsync(taskId); + if (task is null) return; + task.Status = NodeTaskStatus.Completed; + task.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(); + } + + public async Task FailAsync(long taskId, string error) + { + var task = await dbContext.NodeTasks.FindAsync(taskId); + if (task is null) return; + task.Status = NodeTaskStatus.Failed; + task.LastError = error; + task.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(); + } + + public async Task> GetTasksAsync() + { + return await dbContext.NodeTasks.AsNoTracking() + .Include(t => t.Node) + .OrderByDescending(t => t.CreatedAt) + .Take(50) + .ToListAsync(); + } + + public async Task CancelTaskAsync(long taskId) + { + var task = await dbContext.NodeTasks.FindAsync(taskId); + if (task is null) return; + task.Status = NodeTaskStatus.Failed; + task.LastError = "用户取消"; + task.NodeId = null; + task.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(); + } + + // ── Token generation ── + + public static string GenerateToken() => $"nd-{Guid.NewGuid():N}"[..22]; +} + +public sealed record ChapterTaskData( + string ChapterName, + List Sections); + +public sealed record SectionTaskData( + string SectionName, + List Urls); diff --git a/backend/src/UoocProgress.Api/Services/PlatformConnectionService.cs b/backend/src/UoocProgress.Api/Services/PlatformConnectionService.cs new file mode 100644 index 0000000..0078f1e --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/PlatformConnectionService.cs @@ -0,0 +1,1199 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class PlatformConnectionService( + AppDbContext dbContext, + PlatformDefinitionService platformDefinitionService, + PlatformWorkflowExecutor workflowExecutor, + SecretProtectionService secretProtectionService, + SystemSettingsService settingsService, + MockUoocData mockUoocData, + ChallengeSessionService challengeSessionService, + BrowserChallengeService browserChallengeService, + IServiceScopeFactory scopeFactory, + IHttpClientFactory httpClientFactory, + UoocApiService uoocApiService, + ZhihuishuApiService zhihuishuApiService) +{ + public async Task GetActiveSessionDataAsync(long userId, CancellationToken cancellationToken) + { + var connection = await dbContext.UserPlatformConnections + .FirstOrDefaultAsync(c => c.UserAccountId == userId && c.IsActive, cancellationToken) + ?? throw new InvalidOperationException("没有激活的平台连接。"); + + return secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + } + + public async Task> GetConnectionsAsync(long userId, CancellationToken cancellationToken) + { + var connections = await dbContext.UserPlatformConnections + .Include(item => item.PlatformDefinition) + .AsNoTracking() + .Where(item => item.UserAccountId == userId) + .OrderByDescending(item => item.IsActive) + .ThenByDescending(item => item.UpdatedAt) + .ToListAsync(cancellationToken); + + return connections.Select(item => item.ToDto()).ToList(); + } + + public async Task StartLoginAsync( + long userId, + PlatformLoginStartRequest request, + CancellationToken cancellationToken) + { + var platform = await RequirePlatformAsync(request.PlatformId, cancellationToken); + var fields = NormalizeFields(GetFields(platform, PlatformFieldScope.Login), request.Fields); + + var connection = new UserPlatformConnection + { + UserAccountId = userId, + PlatformDefinitionId = platform.Id, + ConnectionName = string.IsNullOrWhiteSpace(request.ConnectionName) + ? platform.DisplayName + : request.ConnectionName.Trim(), + Status = PlatformConnectionStatus.Pending, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + await DeactivateOtherConnectionsAsync(userId, null, cancellationToken); + dbContext.UserPlatformConnections.Add(connection); + await dbContext.SaveChangesAsync(cancellationToken); + + return await RunLoginFlowAsync(connection, platform, fields, new PlatformSessionData(), 0, cancellationToken); + } + + public async Task ReloginAsync( + long userId, + long connectionId, + PlatformReloginRequest request, + CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + var platform = connection.PlatformDefinition!; + var currentFields = secretProtectionService.UnprotectDictionary(connection.EncryptedFieldValues); + foreach (var item in request.Fields) + { + if (!string.IsNullOrWhiteSpace(item.Value)) + { + currentFields[item.Key] = item.Value!.Trim(); + } + } + + var fields = NormalizeFields( + GetFields(platform, PlatformFieldScope.Login), + currentFields.ToDictionary(item => item.Key, item => (string?)item.Value, StringComparer.OrdinalIgnoreCase)); + var sessionData = secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + + await DeactivateOtherConnectionsAsync(userId, connection.Id, cancellationToken); + connection.IsActive = true; + connection.Status = PlatformConnectionStatus.Pending; + connection.LastError = null; + connection.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + return await RunLoginFlowAsync(connection, platform, fields, sessionData, 0, cancellationToken); + } + + public async Task ActivateAsync(long userId, long connectionId, CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + await DeactivateOtherConnectionsAsync(userId, connection.Id, cancellationToken); + connection.IsActive = true; + connection.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(long userId, long connectionId, CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + dbContext.UserPlatformConnections.Remove(connection); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task UoocLoginAsync( + long userId, + UoocLoginRequest request, + CancellationToken cancellationToken) + { + var platform = await RequirePlatformAsync(request.PlatformId, cancellationToken); + + if (!platform.Slug.Equals("uooc", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("此端点仅支持 UOOC 平台登录。"); + } + + var account = request.Account?.Trim() ?? string.Empty; + var password = request.Password?.Trim() ?? string.Empty; + var captchaVerifyParam = request.CaptchaVerifyParam?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(account)) + { + throw new InvalidOperationException("请输入手机号。"); + } + + if (string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException("请输入密码。"); + } + + if (string.IsNullOrWhiteSpace(captchaVerifyParam)) + { + throw new InvalidOperationException("请先完成滑块验证。"); + } + + var encodedPassword = Convert.ToBase64String(Encoding.UTF8.GetBytes(password)); + var formContent = new FormUrlEncodedContent(new Dictionary + { + ["account"] = account, + ["password"] = encodedPassword, + ["captchaVerifyParam"] = captchaVerifyParam, + ["encode"] = "1", + ["remember"] = "true", + ["sceneId"] = "137o3jmc" + }); + + var client = httpClientFactory.CreateClient("platform-workflow"); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/user/login") + { + Content = formContent + }; + + httpRequest.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); + httpRequest.Headers.TryAddWithoutValidation("Origin", "https://www.uooconline.com"); + httpRequest.Headers.TryAddWithoutValidation("Referer", "https://www.uooconline.com/user/login"); + + using var httpResponse = await client.SendAsync(httpRequest, cancellationToken); + var responseText = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + // Try to extract error msg from UOOC JSON response (e.g. {"code":600,"msg":"账号或密码不正确"}) + var uoocErrorMsg = TryExtractUoocMsg(responseText); + + if (httpResponse.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + throw new PlatformOperationException(uoocErrorMsg ?? "UOOC 登录失败:账号或密码错误。", true); + } + + if (!httpResponse.IsSuccessStatusCode) + { + throw new PlatformOperationException(uoocErrorMsg ?? $"UOOC 登录返回了 {(int)httpResponse.StatusCode}。"); + } + + var uoocAuth = ExtractCookie(httpResponse, "uooc_auth"); + if (string.IsNullOrWhiteSpace(uoocAuth)) + { + throw new PlatformOperationException(uoocErrorMsg ?? "UOOC 登录未返回有效会话 Cookie,可能是滑块验证已过期或验证失败。"); + } + + var sessionData = new PlatformSessionData + { + Cookies = { ["uooc_auth"] = uoocAuth } + }; + + var connection = await dbContext.UserPlatformConnections + .SingleOrDefaultAsync( + item => item.UserAccountId == userId && item.PlatformDefinitionId == request.PlatformId, + cancellationToken); + + if (connection is null) + { + connection = new UserPlatformConnection + { + UserAccountId = userId, + PlatformDefinitionId = request.PlatformId, + CreatedAt = DateTimeOffset.UtcNow + }; + dbContext.UserPlatformConnections.Add(connection); + } + + await DeactivateOtherConnectionsAsync(userId, connection.Id, cancellationToken); + + connection.ConnectionName = string.IsNullOrWhiteSpace(request.ConnectionName) + ? platform.DisplayName + : request.ConnectionName.Trim(); + connection.PlatformUserLabel = account; + connection.Status = PlatformConnectionStatus.Connected; + connection.IsActive = true; + connection.LastValidatedAt = DateTimeOffset.UtcNow; + connection.LastSuccessfulLoginAt = DateTimeOffset.UtcNow; + connection.LastError = null; + connection.EncryptedFieldValues = secretProtectionService.ProtectDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["account"] = account, + ["password"] = string.Empty + }); + connection.EncryptedSessionData = secretProtectionService.ProtectSessionData(sessionData); + connection.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + var dto = await ReloadConnectionDtoAsync(connection.Id, cancellationToken); + + return new UoocLoginResponse("connected", "UOOC 登录成功。", dto); + } + + public async Task ZhihuishuLoginAsync( + long userId, + ZhihuishuLoginRequest request, + CancellationToken cancellationToken) + { + var platform = await RequirePlatformAsync(request.PlatformId, cancellationToken); + + if (!platform.Slug.Equals("zhihuishu", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("此端点仅支持智慧树平台登录。"); + } + + var account = request.Account?.Trim() ?? string.Empty; + var password = request.Password?.Trim() ?? string.Empty; + var captchaValidate = request.CaptchaValidate?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(account)) + throw new InvalidOperationException("请输入手机号。"); + + if (string.IsNullOrWhiteSpace(password)) + throw new InvalidOperationException("请输入密码。"); + + if (string.IsNullOrWhiteSpace(captchaValidate)) + throw new InvalidOperationException("请先完成滑块验证。"); + + // Use injected ZhihuishuApiService directly + ZhihuishuApiService.ZhihuishuLoginResult loginResult; + try + { + loginResult = await zhihuishuApiService.LoginAsync(account, password, captchaValidate, cancellationToken); + } + catch (PlatformOperationException) + { + throw; + } + catch (Exception ex) + { + throw new PlatformOperationException($"智慧树登录失败:{ex.Message}"); + } + + var connection = await dbContext.UserPlatformConnections + .SingleOrDefaultAsync( + item => item.UserAccountId == userId && item.PlatformDefinitionId == request.PlatformId, + cancellationToken); + + if (connection is null) + { + connection = new UserPlatformConnection + { + UserAccountId = userId, + PlatformDefinitionId = request.PlatformId, + CreatedAt = DateTimeOffset.UtcNow + }; + dbContext.UserPlatformConnections.Add(connection); + } + + await DeactivateOtherConnectionsAsync(userId, connection.Id, cancellationToken); + + connection.ConnectionName = string.IsNullOrWhiteSpace(request.ConnectionName) + ? platform.DisplayName + : request.ConnectionName.Trim(); + connection.PlatformUserLabel = string.IsNullOrWhiteSpace(loginResult.UserName) + ? account + : loginResult.UserName; + connection.Status = PlatformConnectionStatus.Connected; + connection.IsActive = true; + connection.LastValidatedAt = DateTimeOffset.UtcNow; + connection.LastSuccessfulLoginAt = DateTimeOffset.UtcNow; + connection.LastError = null; + connection.EncryptedFieldValues = secretProtectionService.ProtectDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["account"] = account, + ["password"] = string.Empty + }); + connection.EncryptedSessionData = secretProtectionService.ProtectSessionData(loginResult.SessionData); + connection.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + var dto = await ReloadConnectionDtoAsync(connection.Id, cancellationToken); + + return new ZhihuishuLoginResponse("connected", "智慧树登录成功。", dto); + } + + public async Task QueryCoursesAsync( + long userId, + long connectionId, + PlatformCourseQueryRequest request, + CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + EnsureConnectionReady(connection); + + var platform = connection.PlatformDefinition!; + var sessionData = secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + var fields = NormalizeFields(GetFields(platform, PlatformFieldScope.CourseQuery), request.Fields); + + IReadOnlyList items; + string? message = null; + + if (IsMockUooc(platform, sessionData)) + { + fields.TryGetValue("keyword", out var keyword); + items = mockUoocData.GetCourseOptions(keyword); + message = "当前使用内置 mock 课程数据。"; + } + else if (platform.Slug == "uooc") + { + fields.TryGetValue("keyword", out var keyword); + fields.TryGetValue("page", out var page); + items = await uoocApiService.GetCoursesAsync(sessionData, keyword, page, cancellationToken); + } + else if (platform.Slug == "zhihuishu") + { + fields.TryGetValue("page", out var zhsPage); + var pageNo = int.TryParse(zhsPage, out var p) && p > 0 ? p : 1; + items = await zhihuishuApiService.GetCoursesAsync(sessionData, pageNo, 10, cancellationToken); + // Persist session data — GetCoursesAsync stores course metadata (recruitId, courseId) in Outputs + connection.EncryptedSessionData = secretProtectionService.ProtectSessionData(sessionData); + } + else + { + var state = CreateState(fields, sessionData, new Dictionary()); + items = await workflowExecutor.QueryCourseOptionsAsync(platform, state, cancellationToken); + } + + connection.LastValidatedAt = DateTimeOffset.UtcNow; + connection.UpdatedAt = DateTimeOffset.UtcNow; + connection.LastError = null; + await dbContext.SaveChangesAsync(cancellationToken); + + return new CourseOptionsResponse(connection.Id, platform.DisplayName, items, DateTimeOffset.UtcNow, message); + } + + public async Task GetCatalogAsync( + long userId, + long connectionId, + string courseId, + CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + EnsureConnectionReady(connection); + var platform = connection.PlatformDefinition!; + var sessionData = secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + return await GetCatalogCoreAsync(platform, sessionData, courseId, cancellationToken); + } + + public async Task GetUnitsAsync( + long userId, + long connectionId, + string courseId, + string chapterId, + string sectionId, + CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + EnsureConnectionReady(connection); + var platform = connection.PlatformDefinition!; + var sessionData = secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + return await GetUnitsCoreAsync(platform, sessionData, courseId, chapterId, sectionId, cancellationToken); + } + + public async Task GetProgressAsync( + long userId, + long connectionId, + string courseId, + CancellationToken cancellationToken) + { + var connection = await RequireConnectionAsync(userId, connectionId, cancellationToken); + EnsureConnectionReady(connection); + + var platform = connection.PlatformDefinition!; + var sessionData = secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + + if (platform.Slug == "zhihuishu") + return await GetZhihuishuProgressAsync(sessionData, courseId, cancellationToken); + + var catalog = await GetCatalogCoreAsync(platform, sessionData, courseId, cancellationToken); + var messages = new List(); + if (!string.IsNullOrWhiteSpace(catalog.Message)) + { + messages.Add(catalog.Message); + } + + var chapters = new List(); + var totalSections = 0; + var completedSections = 0; + var inProgressSections = 0; + var totalResources = 0; + var completedResources = 0; + var overallMock = catalog.Mock; + var source = catalog.Source; + + foreach (var chapter in catalog.Chapters) + { + var sectionProgress = new List(); + var chapterCompletedSections = 0; + + foreach (var section in chapter.Sections) + { + totalSections++; + var units = await GetUnitsCoreAsync(platform, sessionData, courseId, chapter.Id, section.Id, cancellationToken); + overallMock |= units.Mock; + source = overallMock ? "mixed" : source; + if (!string.IsNullOrWhiteSpace(units.Message)) + { + messages.Add(units.Message); + } + + var sectionResourceCount = units.Items.Count; + var sectionCompletedResourceCount = units.Items.Count(item => item.Finished); + totalResources += sectionResourceCount; + completedResources += sectionCompletedResourceCount; + + var hasActivity = units.Items.Any(item => item.Finished || item.VideoPosition > 0) || section.Learning; + var isFinished = section.Finished + || (sectionResourceCount > 0 && sectionCompletedResourceCount == sectionResourceCount); + var state = isFinished + ? "completed" + : hasActivity + ? "in-progress" + : sectionResourceCount == 0 + ? "no-resource" + : "not-started"; + + if (isFinished) + { + completedSections++; + chapterCompletedSections++; + } + else if (state == "in-progress") + { + inProgressSections++; + } + + sectionProgress.Add( + new SectionProgressDto( + section.Id, + section.Number, + section.Name, + isFinished, + section.Learning, + state, + sectionResourceCount, + sectionCompletedResourceCount, + units.Items)); + } + + chapters.Add( + new ChapterProgressDto( + chapter.Id, + chapter.Number, + chapter.Name, + chapter.Sections.Count > 0 && chapterCompletedSections == chapter.Sections.Count, + chapterCompletedSections, + chapter.Sections.Count, + sectionProgress)); + } + + return new CourseProgressResponse( + courseId, + mockUoocData.TryGetCourseName(courseId) ?? courseId, + new ProgressSummaryDto( + totalSections, + completedSections, + inProgressSections, + totalResources, + completedResources, + ToRate(completedSections, totalSections), + ToRate(completedResources, totalResources)), + chapters, + DateTimeOffset.UtcNow, + overallMock, + overallMock ? "mixed" : source, + BuildMessage(messages)); + } + + public async Task CompleteChallengeAsync( + long connectionId, + int nextStepIndex, + IReadOnlyDictionary browserCookies) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ResumeAfterChallengeAsync(connectionId, nextStepIndex, browserCookies, CancellationToken.None); + } + + private async Task ResumeAfterChallengeAsync( + long connectionId, + int nextStepIndex, + IReadOnlyDictionary browserCookies, + CancellationToken cancellationToken) + { + var connection = await dbContext.UserPlatformConnections + .Include(item => item.PlatformDefinition) + .ThenInclude(item => item!.FieldDefinitions) + .Include(item => item.PlatformDefinition) + .ThenInclude(item => item!.WorkflowSteps) + .SingleAsync(item => item.Id == connectionId, cancellationToken); + + var fields = secretProtectionService.UnprotectDictionary(connection.EncryptedFieldValues); + var sessionData = secretProtectionService.UnprotectSessionData(connection.EncryptedSessionData); + foreach (var cookie in browserCookies) + { + sessionData.Cookies[cookie.Key] = cookie.Value; + } + + try + { + await RunLoginFlowAsync(connection, connection.PlatformDefinition!, fields, sessionData, nextStepIndex, cancellationToken); + } + catch (Exception exception) + { + connection.Status = PlatformConnectionStatus.Failed; + connection.LastError = exception.Message; + connection.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + throw; + } + } + + private async Task RunLoginFlowAsync( + UserPlatformConnection connection, + PlatformDefinition platform, + IReadOnlyDictionary fields, + PlatformSessionData sessionData, + int startStepIndex, + CancellationToken cancellationToken) + { + var state = CreateState(fields, sessionData, new Dictionary()); + var steps = workflowExecutor.GetSteps(platform, PlatformWorkflowScope.Login); + var settings = await settingsService.GetEntityAsync(cancellationToken); + + for (var index = startStepIndex; index < steps.Count; index++) + { + var step = steps[index]; + if (step.StepType == PlatformWorkflowStepType.BrowserChallenge) + { + var launchUrl = workflowExecutor.ResolveTemplate(step.UrlTemplate, state); + if (string.IsNullOrWhiteSpace(launchUrl)) + { + throw new PlatformOperationException("浏览器挑战步骤缺少启动地址。"); + } + + var challenge = challengeSessionService.Create( + connection.UserAccountId, + connection.Id, + "已启动浏览器验证,请在弹出的窗口完成验证。", + launchUrl, + step.BrowserTimeoutSeconds ?? settings.BrowserChallengeTimeoutSeconds); + + connection.Status = PlatformConnectionStatus.ChallengePending; + connection.IsActive = true; + connection.EncryptedFieldValues = secretProtectionService.ProtectDictionary(new Dictionary(fields, StringComparer.OrdinalIgnoreCase)); + connection.EncryptedSessionData = secretProtectionService.ProtectSessionData(sessionData); + connection.LastError = null; + connection.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + browserChallengeService.RunChallenge( + challenge.Id, + launchUrl, + step.BrowserSuccessUrlContains, + step.BrowserSuccessCookieName, + step.BrowserWaitForSelector, + step.BrowserTimeoutSeconds ?? settings.BrowserChallengeTimeoutSeconds, + cookies => CompleteChallengeAsync(connection.Id, index + 1, new Dictionary(cookies, StringComparer.OrdinalIgnoreCase))); + + return new PlatformLoginStartResponse( + "challenge_required", + "已启动浏览器验证,请在弹出的窗口完成验证。", + await ReloadConnectionDtoAsync(connection.Id, cancellationToken), + challenge.Id, + challenge.ChallengeUrl); + } + + await workflowExecutor.ExecuteLoginStepAsync(step, state, cancellationToken); + if (!string.IsNullOrWhiteSpace(step.PlatformUserLabelExpression)) + { + var label = workflowExecutor.ResolveTemplate(step.PlatformUserLabelExpression, state).Trim(); + if (!string.IsNullOrWhiteSpace(label)) + { + connection.PlatformUserLabel = label; + } + } + } + + connection.Status = PlatformConnectionStatus.Connected; + connection.IsActive = true; + connection.LastValidatedAt = DateTimeOffset.UtcNow; + connection.LastSuccessfulLoginAt = DateTimeOffset.UtcNow; + connection.LastError = null; + connection.PlatformUserLabel ??= fields.TryGetValue("username", out var username) && !string.IsNullOrWhiteSpace(username) + ? username + : connection.ConnectionName; + connection.EncryptedFieldValues = secretProtectionService.ProtectDictionary(new Dictionary(fields, StringComparer.OrdinalIgnoreCase)); + connection.EncryptedSessionData = secretProtectionService.ProtectSessionData(state.SessionData); + connection.UpdatedAt = DateTimeOffset.UtcNow; + + await DeactivateOtherConnectionsAsync(connection.UserAccountId, connection.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + return new PlatformLoginStartResponse( + "connected", + "平台登录成功。", + await ReloadConnectionDtoAsync(connection.Id, cancellationToken), + null, + null); + } + + private async Task GetCatalogCoreAsync( + PlatformDefinition platform, + PlatformSessionData sessionData, + string courseId, + CancellationToken cancellationToken) + { + if (IsMockUooc(platform, sessionData)) + return mockUoocData.GetCatalog(courseId); + + if (platform.Slug == "uooc") + return await uoocApiService.GetCatalogAsync(sessionData, courseId, cancellationToken); + + if (platform.Slug == "zhihuishu") + { + var catalog = await zhihuishuApiService.GetCatalogAsync(sessionData, courseId, cancellationToken); + return await EnrichZhihuishuCatalogWithProgressAsync(sessionData, catalog, cancellationToken); + } + + return await workflowExecutor.ReadCatalogAsync(platform, CreateState( + new Dictionary(), + sessionData, + new Dictionary { ["courseId"] = courseId }), cancellationToken); + } + + private async Task GetUnitsCoreAsync( + PlatformDefinition platform, + PlatformSessionData sessionData, + string courseId, + string chapterId, + string sectionId, + CancellationToken cancellationToken) + { + if (IsMockUooc(platform, sessionData)) + return mockUoocData.GetUnits(courseId, chapterId, sectionId); + + if (platform.Slug == "uooc") + return await uoocApiService.GetUnitsAsync(sessionData, courseId, chapterId, sectionId, cancellationToken); + + if (platform.Slug == "zhihuishu") + return await GetZhihuishuUnitsAsync(sessionData, courseId, chapterId, sectionId, cancellationToken); + + return await workflowExecutor.ReadUnitsAsync(platform, CreateState( + new Dictionary(), + sessionData, + new Dictionary { ["courseId"] = courseId, ["chapterId"] = chapterId, ["sectionId"] = sectionId }), cancellationToken); + } + + /// + /// Enriches the zhihuishu catalog (from videolist API) with real learning progress + /// from queryStuyInfo, so sections show their actual finished/learning status inline. + /// + private async Task EnrichZhihuishuCatalogWithProgressAsync( + PlatformSessionData sessionData, + CatalogResponse catalog, + CancellationToken ct) + { + if (catalog.Chapters.Count == 0) return catalog; + + // Collect all lesson IDs and small lesson IDs from TaskId metadata + var allLessonIds = new List(); + var allVideoIds = new List(); + + foreach (var ch in catalog.Chapters) + { + foreach (var sec in ch.Sections) + { + allLessonIds.Add(sec.Id); + + try + { + using var videoDoc = JsonDocument.Parse(sec.TaskId); + foreach (var vi in videoDoc.RootElement.EnumerateArray()) + { + var slId = vi.TryGetProperty("slId", out var s) ? s.GetString() ?? "" : ""; + var vId = vi.TryGetProperty("vId", out var v) ? v.GetString() ?? "" : ""; + if (!string.IsNullOrWhiteSpace(slId) && !string.IsNullOrWhiteSpace(vId) && slId != "0") + allVideoIds.Add(slId); + } + } + catch { } + } + } + + // Fetch progress from queryStuyInfo + var (recruitId, _) = ZhihuishuApiService.GetCourseMeta(sessionData, catalog.CourseId); + if (string.IsNullOrWhiteSpace(recruitId)) recruitId = "389213"; + + Dictionary progress; + try + { + progress = await zhihuishuApiService.QueryLessonProgressAsync( + sessionData, allLessonIds, allVideoIds, recruitId, ct); + } + catch (Exception ex) + { + Console.WriteLine($"[Zhihuishu] Catalog progress enrichment failed: {ex.Message}"); + return catalog; // return original catalog without progress + } + + // Merge progress into sections and chapters + var enrichedChapters = new List(); + foreach (var ch in catalog.Chapters) + { + var enrichedSections = new List(); + bool chapterAllFinished = ch.Sections.Count > 0; + bool chapterAnyLearning = false; + + foreach (var sec in ch.Sections) + { + bool isFinished = false; + bool isLearning = false; + + // Check lesson-level progress + if (progress.TryGetValue(sec.Id, out var lp)) + { + isFinished = lp.WatchState == 1; + isLearning = lp.StudyTotalTime > 0; + } + + // Also check small-lesson level (from TaskId JSON) + if (!isFinished) + { + try + { + using var videoDoc = JsonDocument.Parse(sec.TaskId); + foreach (var vi in videoDoc.RootElement.EnumerateArray()) + { + var slId = vi.TryGetProperty("slId", out var s) ? s.GetString() ?? "" : ""; + if (!string.IsNullOrWhiteSpace(slId) && progress.TryGetValue(slId, out var slp)) + { + if (slp.WatchState == 1) isFinished = true; + if (slp.StudyTotalTime > 0) isLearning = true; + } + } + } + catch { } + } + + if (!isFinished) chapterAllFinished = false; + if (isLearning) chapterAnyLearning = true; + + enrichedSections.Add(new CatalogSectionDto( + sec.Id, sec.Number, sec.Name, isFinished, isLearning, sec.TaskId)); + } + + enrichedChapters.Add(new CatalogChapterDto( + ch.Id, ch.Number, ch.Name, + chapterAllFinished, chapterAnyLearning, + enrichedSections)); + } + + return new CatalogResponse(catalog.CourseId, enrichedChapters, catalog.Mock, catalog.Source, catalog.Message); + } + + private async Task GetZhihuishuProgressAsync( + PlatformSessionData sessionData, + string courseId, // RAC_id + CancellationToken cancellationToken) + { + var catalog = await zhihuishuApiService.GetCatalogAsync(sessionData, courseId, cancellationToken); + + // Collect all lesson IDs and their small lesson IDs from the catalog + var allLessonIds = new List(); + var allVideoIds = new List(); + var lessonMeta = new Dictionary SmallLessonIds)>(StringComparer.OrdinalIgnoreCase); + + foreach (var ch in catalog.Chapters) + { + foreach (var sec in ch.Sections) + { + allLessonIds.Add(sec.Id); + lessonMeta[sec.Id] = (ch.Id, ch.Name, sec.Name, new List()); + + // Parse video metadata from TaskId JSON + try + { + using var videoDoc = JsonDocument.Parse(sec.TaskId); + foreach (var vi in videoDoc.RootElement.EnumerateArray()) + { + var slId = vi.TryGetProperty("slId", out var s) ? s.GetString() ?? "" : ""; + var vId = vi.TryGetProperty("vId", out var v) ? v.GetString() ?? "" : ""; + if (!string.IsNullOrWhiteSpace(slId)) + { + lessonMeta[sec.Id].SmallLessonIds.Add(slId); + if (!string.IsNullOrWhiteSpace(vId)) + allVideoIds.Add(slId); // smallLessonId as video-level key + } + } + } + catch { } + } + } + + // Fetch progress from queryStuyInfo — batch all lessons at once + var (recruitId, _) = ZhihuishuApiService.GetCourseMeta(sessionData, courseId); + if (string.IsNullOrWhiteSpace(recruitId)) recruitId = "389213"; // fallback from catalog data + + Dictionary progress; + try + { + progress = await zhihuishuApiService.QueryLessonProgressAsync( + sessionData, allLessonIds, allVideoIds, recruitId, cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"[Zhihuishu] Progress query failed: {ex.Message}"); + progress = new(); + } + + var chapters = new List(); + var totalSections = 0; + var completedSections = 0; + var inProgressSections = 0; + var totalResources = 0; + var completedResources = 0; + + foreach (var ch in catalog.Chapters) + { + var sectionProgressList = new List(); + var chapterCompletedSections = 0; + + foreach (var sec in ch.Sections) + { + totalSections++; + var meta = lessonMeta.GetValueOrDefault(sec.Id); + var resourceCount = meta.SmallLessonIds.Count > 0 ? meta.SmallLessonIds.Count : 1; + + // Check progress for this lesson + var isFinished = false; + var hasActivity = false; + double studyTime = 0; + + // Check lesson-level progress + if (progress.TryGetValue(sec.Id, out var lp)) + { + isFinished = lp.WatchState == 1; + hasActivity = lp.StudyTotalTime > 0; + studyTime = lp.StudyTotalTime; + } + + // Also check small lesson level + foreach (var slId in meta.SmallLessonIds) + { + if (progress.TryGetValue(slId, out var slp)) + { + if (slp.WatchState == 1) isFinished = true; + if (slp.StudyTotalTime > 0) hasActivity = true; + } + } + + if (isFinished) + { + completedSections++; + chapterCompletedSections++; + } + else if (hasActivity || sec.Learning) + { + inProgressSections++; + } + + var resourceItems = meta.SmallLessonIds.Select(slId => + { + var slProgress = progress.GetValueOrDefault(slId); + return new UnitItemDto( + slId, sec.Name, "video", + slProgress?.WatchState == 1, + true, + slProgress?.StudyTotalTime ?? 0, + null, null, null, 0, [], slId); + }).ToList(); + + if (resourceItems.Count == 0) + { + resourceItems.Add(new UnitItemDto( + sec.Id, sec.Name, "video", + isFinished, true, studyTime, + null, null, null, 0, [], sec.Id)); + } + + var sectionCompletedCount = resourceItems.Count(r => r.Finished); + totalResources += resourceItems.Count; + completedResources += sectionCompletedCount; + + var state = isFinished ? "completed" + : hasActivity || sec.Learning ? "in-progress" + : resourceItems.Count == 0 ? "no-resource" + : "not-started"; + + sectionProgressList.Add(new SectionProgressDto( + sec.Id, sec.Number, sec.Name, + isFinished, sec.Learning, state, + resourceItems.Count, sectionCompletedCount, resourceItems)); + } + + chapters.Add(new ChapterProgressDto( + ch.Id, ch.Number, ch.Name, + ch.Sections.Count > 0 && chapterCompletedSections == ch.Sections.Count, + chapterCompletedSections, ch.Sections.Count, sectionProgressList)); + } + + return new CourseProgressResponse( + courseId, courseId, + new ProgressSummaryDto( + totalSections, completedSections, inProgressSections, + totalResources, completedResources, + ToRate(completedSections, totalSections), + ToRate(completedResources, totalResources)), + chapters, DateTimeOffset.UtcNow, false, "upstream", null); + } + + private async Task GetZhihuishuUnitsAsync( + PlatformSessionData sessionData, + string courseId, // RAC_id + string chapterId, + string sectionId, // lessonId + CancellationToken cancellationToken) + { + // Re-fetch catalog to get video details for this section/lesson + var catalog = await zhihuishuApiService.GetCatalogAsync(sessionData, courseId, cancellationToken); + var items = new List(); + + foreach (var ch in catalog.Chapters) + { + if (ch.Id != chapterId) continue; + foreach (var sec in ch.Sections) + { + if (sec.Id != sectionId) continue; + + // sec.TaskId contains JSON array of {slId, vId, vSec} + try + { + using var videoDoc = JsonDocument.Parse(sec.TaskId); + foreach (var vi in videoDoc.RootElement.EnumerateArray()) + { + var slId = vi.TryGetProperty("slId", out var s) ? s.GetString() ?? "" : ""; + var vId = vi.TryGetProperty("vId", out var v) ? v.GetString() ?? "" : ""; + var vSec = vi.TryGetProperty("vSec", out var d) ? d.GetDouble() : 0; + + items.Add(new UnitItemDto( + vId, // id = videoId + sec.Name, // title = lesson name + "video", + false, + !string.IsNullOrWhiteSpace(vId), + 0, // videoPosition + vSec, // videoLength + sec.Name, + null, // primarySourceUrl + 0, + [], + slId)); // catalogId = smallLessonId + } + } + catch + { + // Fallback: create single item from section + items.Add(new UnitItemDto( + sec.Id, sec.Name, "video", false, true, + 0, null, sec.Name, null, 0, [], sec.TaskId)); + } + break; + } + break; + } + + return new UnitsResponse(courseId, chapterId, sectionId, items, false, "upstream", null); + } + + private static WorkflowExecutionState CreateState( + IReadOnlyDictionary fields, + PlatformSessionData sessionData, + IReadOnlyDictionary context) => + new() + { + InputFields = new Dictionary(fields, StringComparer.OrdinalIgnoreCase), + SessionData = sessionData, + ContextValues = new Dictionary(context, StringComparer.OrdinalIgnoreCase) + }; + + private async Task RequirePlatformAsync(long platformId, CancellationToken cancellationToken) + { + var platform = await platformDefinitionService.FindEntityAsync(platformId, cancellationToken); + if (platform is null || platform.Status != PlatformStatus.Active) + { + throw new InvalidOperationException("平台不存在或尚未启用。"); + } + + return platform; + } + + private async Task RequireConnectionAsync( + long userId, + long connectionId, + CancellationToken cancellationToken) + { + var connection = await dbContext.UserPlatformConnections + .Include(item => item.PlatformDefinition) + .ThenInclude(item => item!.FieldDefinitions) + .Include(item => item.PlatformDefinition) + .ThenInclude(item => item!.WorkflowSteps) + .SingleOrDefaultAsync( + item => item.Id == connectionId && item.UserAccountId == userId, + cancellationToken); + + return connection ?? throw new InvalidOperationException("平台连接不存在。"); + } + + private static IReadOnlyList GetFields(PlatformDefinition platform, PlatformFieldScope scope) => + platform.FieldDefinitions + .Where(item => item.Scope == scope) + .OrderBy(item => item.DisplayOrder) + .ToList(); + + private static Dictionary NormalizeFields( + IReadOnlyList definitions, + IReadOnlyDictionary rawFields) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var definition in definitions) + { + var value = rawFields.TryGetValue(definition.Key, out var rawValue) + ? rawValue?.Trim() + : definition.DefaultValue?.Trim(); + + if (definition.IsRequired && string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"请填写“{definition.Label}”。"); + } + + result[definition.Key] = value ?? string.Empty; + } + + return result; + } + + private static void EnsureConnectionReady(UserPlatformConnection connection) + { + if (connection.Status == PlatformConnectionStatus.ChallengePending) + { + throw new InvalidOperationException("当前连接仍在等待浏览器验证完成。"); + } + + if (connection.Status != PlatformConnectionStatus.Connected) + { + throw new InvalidOperationException("当前连接尚未完成登录,请先重新连接平台。"); + } + } + + private bool IsMockUooc(PlatformDefinition platform, PlatformSessionData sessionData) => + platform.Slug == "uooc" + && sessionData.Cookies.TryGetValue("uooc_auth", out var sessionToken) + && mockUoocData.IsDemoToken(sessionToken); + + private async Task DeactivateOtherConnectionsAsync( + long userId, + long? excludedConnectionId, + CancellationToken cancellationToken) + { + var connections = await dbContext.UserPlatformConnections + .Where(item => item.UserAccountId == userId && item.Id != excludedConnectionId && item.IsActive) + .ToListAsync(cancellationToken); + + foreach (var connection in connections) + { + connection.IsActive = false; + connection.UpdatedAt = DateTimeOffset.UtcNow; + } + } + + private async Task ReloadConnectionDtoAsync(long connectionId, CancellationToken cancellationToken) + { + var connection = await dbContext.UserPlatformConnections + .Include(item => item.PlatformDefinition) + .AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == connectionId, cancellationToken); + + return connection?.ToDto(); + } + + private static double ToRate(int completed, int total) => + total == 0 ? 0 : Math.Round((double)completed / total, 4); + + private static string? BuildMessage(IEnumerable messages) + { + var values = messages + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return values.Length == 0 ? null : string.Join(" ", values); + } + + private static string? TryExtractUoocMsg(string? responseText) + { + if (string.IsNullOrWhiteSpace(responseText)) + { + return null; + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(responseText); + if (doc.RootElement.TryGetProperty("msg", out var msgElement) + && msgElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + var msg = msgElement.GetString(); + return string.IsNullOrWhiteSpace(msg) ? null : msg; + } + } + catch + { + // Not valid JSON; ignore + } + + return null; + } + + private static string? ExtractCookie(HttpResponseMessage response, string cookieName) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var values)) + { + return null; + } + + foreach (var raw in values) + { + var parts = raw.Split(';'); + foreach (var part in parts) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith(cookieName + "=", StringComparison.OrdinalIgnoreCase)) + { + var value = trimmed[(cookieName.Length + 1)..]; + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + } + + return null; + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength] + "..."; +} diff --git a/backend/src/UoocProgress.Api/Services/PlatformDefinitionService.cs b/backend/src/UoocProgress.Api/Services/PlatformDefinitionService.cs new file mode 100644 index 0000000..51a138e --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/PlatformDefinitionService.cs @@ -0,0 +1,284 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class PlatformDefinitionService(AppDbContext dbContext) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> GetAdminListAsync(CancellationToken cancellationToken) => + await dbContext.PlatformDefinitions + .AsNoTracking() + .OrderBy(item => item.DisplayName) + .Select(item => item.ToSummaryDto()) + .ToListAsync(cancellationToken); + + public async Task> GetActiveListAsync(string defaultVisibility, CancellationToken cancellationToken) => + await dbContext.PlatformDefinitions + .AsNoTracking() + .Where(item => defaultVisibility == "all" || item.Status == PlatformStatus.Active) + .OrderBy(item => item.DisplayName) + .Select(item => item.ToSummaryDto()) + .ToListAsync(cancellationToken); + + public async Task FindEntityAsync(long platformId, CancellationToken cancellationToken) => + await dbContext.PlatformDefinitions + .Include(item => item.FieldDefinitions) + .Include(item => item.WorkflowSteps) + .SingleOrDefaultAsync(item => item.Id == platformId, cancellationToken); + + public async Task GetByIdAsync(long platformId, CancellationToken cancellationToken) + { + var entity = await FindEntityAsync(platformId, cancellationToken); + return entity?.ToDto(); + } + + public async Task CreateAsync(SavePlatformDefinitionRequest request, CancellationToken cancellationToken) + { + ValidateRequest(request); + + var slug = request.Slug.Trim().ToLowerInvariant(); + var exists = await dbContext.PlatformDefinitions.AnyAsync(item => item.Slug == slug, cancellationToken); + if (exists) + { + throw new InvalidOperationException("平台标识已存在,请更换 slug。"); + } + + var entity = new PlatformDefinition(); + Apply(entity, request); + dbContext.PlatformDefinitions.Add(entity); + await dbContext.SaveChangesAsync(cancellationToken); + return (await FindEntityAsync(entity.Id, cancellationToken))!.ToDto(); + } + + public async Task UpdateAsync(long platformId, SavePlatformDefinitionRequest request, CancellationToken cancellationToken) + { + ValidateRequest(request); + + var entity = await FindEntityAsync(platformId, cancellationToken) + ?? throw new InvalidOperationException("平台不存在。"); + + var slug = request.Slug.Trim().ToLowerInvariant(); + var exists = await dbContext.PlatformDefinitions.AnyAsync( + item => item.Id != platformId && item.Slug == slug, + cancellationToken); + + if (exists) + { + throw new InvalidOperationException("平台标识已存在,请更换 slug。"); + } + + Apply(entity, request); + await dbContext.SaveChangesAsync(cancellationToken); + return (await FindEntityAsync(platformId, cancellationToken))!.ToDto(); + } + + public async Task CloneAsync(long platformId, CancellationToken cancellationToken) + { + var entity = await FindEntityAsync(platformId, cancellationToken) + ?? throw new InvalidOperationException("平台不存在。"); + + var clone = new PlatformDefinition + { + Slug = $"{entity.Slug}-copy-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", + DisplayName = $"{entity.DisplayName} 副本", + Description = entity.Description, + Status = PlatformStatus.Draft, + EnableBrowserChallenge = entity.EnableBrowserChallenge, + CourseQueryStepKey = entity.CourseQueryStepKey, + SupportsCatalog = entity.SupportsCatalog, + SupportsUnits = entity.SupportsUnits, + SupportsProgress = entity.SupportsProgress, + ChallengeTimeoutSeconds = entity.ChallengeTimeoutSeconds, + FieldDefinitions = entity.FieldDefinitions.Select( + item => new PlatformFieldDefinition + { + Scope = item.Scope, + Key = item.Key, + Label = item.Label, + Type = item.Type, + IsRequired = item.IsRequired, + DisplayOrder = item.DisplayOrder, + Placeholder = item.Placeholder, + HelpText = item.HelpText, + DefaultValue = item.DefaultValue, + IsSensitive = item.IsSensitive, + OptionsJson = item.OptionsJson + }) + .ToList(), + WorkflowSteps = entity.WorkflowSteps.Select( + item => new PlatformWorkflowStep + { + Scope = item.Scope, + StepKey = item.StepKey, + DisplayName = item.DisplayName, + DisplayOrder = item.DisplayOrder, + StepType = item.StepType, + HttpMethod = item.HttpMethod, + UrlTemplate = item.UrlTemplate, + QueryTemplateJson = item.QueryTemplateJson, + HeadersTemplateJson = item.HeadersTemplateJson, + BodyTemplateJson = item.BodyTemplateJson, + ContentType = item.ContentType, + SuccessPath = item.SuccessPath, + SuccessExpectedValue = item.SuccessExpectedValue, + PlatformUserLabelExpression = item.PlatformUserLabelExpression, + OutputCookiesJson = item.OutputCookiesJson, + OutputVariablesJson = item.OutputVariablesJson, + CourseOptionMappingJson = item.CourseOptionMappingJson, + CatalogMappingJson = item.CatalogMappingJson, + UnitMappingJson = item.UnitMappingJson, + BrowserSuccessUrlContains = item.BrowserSuccessUrlContains, + BrowserSuccessCookieName = item.BrowserSuccessCookieName, + BrowserWaitForSelector = item.BrowserWaitForSelector, + BrowserTimeoutSeconds = item.BrowserTimeoutSeconds, + IsEnabled = item.IsEnabled + }) + .ToList() + }; + + dbContext.PlatformDefinitions.Add(clone); + await dbContext.SaveChangesAsync(cancellationToken); + return (await FindEntityAsync(clone.Id, cancellationToken))!.ToDto(); + } + + public async Task UpdateStatusAsync(long platformId, string statusValue, CancellationToken cancellationToken) + { + var entity = await FindEntityAsync(platformId, cancellationToken) + ?? throw new InvalidOperationException("平台不存在。"); + + if (!EnumValueCodec.TryParsePlatformStatus(statusValue, out var status)) + { + throw new InvalidOperationException("平台状态仅支持 draft、active、disabled。"); + } + + entity.Status = status; + await dbContext.SaveChangesAsync(cancellationToken); + return entity.ToDto(); + } + + private void Apply(PlatformDefinition entity, SavePlatformDefinitionRequest request) + { + if (!EnumValueCodec.TryParsePlatformStatus(request.Status, out var status)) + { + throw new InvalidOperationException("平台状态仅支持 draft、active、disabled。"); + } + + entity.Slug = request.Slug.Trim().ToLowerInvariant(); + entity.DisplayName = request.DisplayName.Trim(); + entity.Description = request.Description.Trim(); + entity.Status = status; + entity.EnableBrowserChallenge = request.EnableBrowserChallenge; + entity.CourseQueryStepKey = string.IsNullOrWhiteSpace(request.CourseQueryStepKey) + ? null + : request.CourseQueryStepKey.Trim(); + entity.SupportsCatalog = request.SupportsCatalog; + entity.SupportsUnits = request.SupportsUnits; + entity.SupportsProgress = request.SupportsProgress; + entity.ChallengeTimeoutSeconds = Math.Max(request.ChallengeTimeoutSeconds, 30); + + entity.FieldDefinitions.Clear(); + foreach (var field in request.Fields.OrderBy(item => item.DisplayOrder)) + { + if (!EnumValueCodec.TryParsePlatformFieldScope(field.Scope, out var scope)) + { + throw new InvalidOperationException($"字段 {field.Key} 的 scope 不合法。"); + } + + if (!EnumValueCodec.TryParsePlatformFieldType(field.Type, out var type)) + { + throw new InvalidOperationException($"字段 {field.Key} 的 type 不合法。"); + } + + entity.FieldDefinitions.Add( + new PlatformFieldDefinition + { + Scope = scope, + Key = field.Key.Trim(), + Label = field.Label.Trim(), + Type = type, + IsRequired = field.IsRequired, + DisplayOrder = field.DisplayOrder, + Placeholder = field.Placeholder?.Trim(), + HelpText = field.HelpText?.Trim(), + DefaultValue = field.DefaultValue, + IsSensitive = field.IsSensitive, + OptionsJson = field.Options.Count == 0 ? null : JsonSerializer.Serialize(field.Options, JsonOptions) + }); + } + + entity.WorkflowSteps.Clear(); + foreach (var step in request.Steps.OrderBy(item => item.DisplayOrder)) + { + if (!EnumValueCodec.TryParsePlatformWorkflowScope(step.Scope, out var scope)) + { + throw new InvalidOperationException($"步骤 {step.StepKey} 的 scope 不合法。"); + } + + if (!EnumValueCodec.TryParsePlatformWorkflowStepType(step.StepType, out var stepType)) + { + throw new InvalidOperationException($"步骤 {step.StepKey} 的类型不合法。"); + } + + entity.WorkflowSteps.Add( + new PlatformWorkflowStep + { + Scope = scope, + StepKey = step.StepKey.Trim(), + DisplayName = step.DisplayName.Trim(), + DisplayOrder = step.DisplayOrder, + StepType = stepType, + HttpMethod = string.IsNullOrWhiteSpace(step.HttpMethod) ? "GET" : step.HttpMethod.Trim().ToUpperInvariant(), + UrlTemplate = step.UrlTemplate?.Trim(), + QueryTemplateJson = NormalizeJson(step.QueryTemplateJson), + HeadersTemplateJson = NormalizeJson(step.HeadersTemplateJson), + BodyTemplateJson = NormalizeJson(step.BodyTemplateJson), + ContentType = string.IsNullOrWhiteSpace(step.ContentType) ? null : step.ContentType.Trim(), + SuccessPath = string.IsNullOrWhiteSpace(step.SuccessPath) ? null : step.SuccessPath.Trim(), + SuccessExpectedValue = string.IsNullOrWhiteSpace(step.SuccessExpectedValue) ? null : step.SuccessExpectedValue.Trim(), + PlatformUserLabelExpression = string.IsNullOrWhiteSpace(step.PlatformUserLabelExpression) ? null : step.PlatformUserLabelExpression.Trim(), + OutputCookiesJson = step.OutputCookies.Count == 0 ? null : JsonSerializer.Serialize(step.OutputCookies, JsonOptions), + OutputVariablesJson = step.OutputVariables.Count == 0 ? null : JsonSerializer.Serialize(step.OutputVariables, JsonOptions), + CourseOptionMappingJson = step.CourseOptionMapping is null ? null : JsonSerializer.Serialize(step.CourseOptionMapping, JsonOptions), + CatalogMappingJson = step.CatalogMapping is null ? null : JsonSerializer.Serialize(step.CatalogMapping, JsonOptions), + UnitMappingJson = step.UnitMapping is null ? null : JsonSerializer.Serialize(step.UnitMapping, JsonOptions), + BrowserSuccessUrlContains = step.BrowserSuccessUrlContains?.Trim(), + BrowserSuccessCookieName = step.BrowserSuccessCookieName?.Trim(), + BrowserWaitForSelector = step.BrowserWaitForSelector?.Trim(), + BrowserTimeoutSeconds = step.BrowserTimeoutSeconds, + BrowserAutomationJson = string.IsNullOrWhiteSpace(step.BrowserAutomationJson) ? null : step.BrowserAutomationJson.Trim(), + IsEnabled = step.IsEnabled + }); + } + } + + private static void ValidateRequest(SavePlatformDefinitionRequest request) + { + if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.DisplayName)) + { + throw new InvalidOperationException("平台 slug 和显示名不能为空。"); + } + + if (request.Fields.Count == 0) + { + throw new InvalidOperationException("至少需要配置一个平台字段。"); + } + + if (request.Steps.Count == 0) + { + throw new InvalidOperationException("至少需要配置一个平台步骤。"); + } + + if (!string.IsNullOrWhiteSpace(request.CourseQueryStepKey) + && request.Steps.All(item => !item.StepKey.Equals(request.CourseQueryStepKey, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException("课程下拉来源步骤不存在。"); + } + } + + private static string? NormalizeJson(string? json) => + string.IsNullOrWhiteSpace(json) ? null : json.Trim(); +} diff --git a/backend/src/UoocProgress.Api/Services/PlatformWorkflowExecutor.cs b/backend/src/UoocProgress.Api/Services/PlatformWorkflowExecutor.cs new file mode 100644 index 0000000..4d5d7b6 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/PlatformWorkflowExecutor.cs @@ -0,0 +1,670 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; + +namespace UoocProgress.Api.Services; + +public sealed class PlatformWorkflowExecutor( + IHttpClientFactory httpClientFactory, + IOptions uoocOptions, + TemplateResolver templateResolver, + SimpleJsonPathService jsonPathService) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public IReadOnlyList GetSteps(PlatformDefinition platform, PlatformWorkflowScope scope) => + platform.WorkflowSteps + .Where(item => item.Scope == scope && item.IsEnabled) + .OrderBy(item => item.DisplayOrder) + .ToList(); + + public string ResolveTemplate(string? template, WorkflowExecutionState state) => + templateResolver.Resolve( + template, + state.InputFields, + state.SessionData, + BuildContext(state.ContextValues), + state.SessionData.StepOutputs); + + public async Task ExecuteLoginStepAsync( + PlatformWorkflowStep step, + WorkflowExecutionState state, + CancellationToken cancellationToken) + { + switch (step.StepType) + { + case PlatformWorkflowStepType.SessionPassthrough: + ApplyPassthroughStep(step, state); + return; + case PlatformWorkflowStepType.HttpRequest: + await ExecuteHttpRequestStepAsync(step, state, cancellationToken); + return; + case PlatformWorkflowStepType.BrowserChallenge: + throw new PlatformOperationException("浏览器挑战步骤需要由连接服务单独接管。"); + default: + throw new PlatformOperationException($"不支持的平台步骤类型:{step.StepType}"); + } + } + + public async Task> QueryCourseOptionsAsync( + PlatformDefinition platform, + WorkflowExecutionState state, + CancellationToken cancellationToken) + { + JsonNode? responseJson = null; + CourseOptionMappingDto? mapping = null; + var targetStepKey = platform.CourseQueryStepKey; + + foreach (var step in GetSteps(platform, PlatformWorkflowScope.CourseQuery)) + { + var result = await ExecuteStepAsync(step, state, cancellationToken); + var stepMapping = Deserialize(step.CourseOptionMappingJson); + var isTarget = string.IsNullOrWhiteSpace(targetStepKey) + ? stepMapping is not null + : step.StepKey.Equals(targetStepKey, StringComparison.OrdinalIgnoreCase); + + if (isTarget) + { + mapping = stepMapping ?? throw new PlatformOperationException("课程下拉来源步骤未配置课程映射。"); + responseJson = result.ResponseJson; + } + } + + if (mapping is null || responseJson is null) + { + throw new PlatformOperationException("平台未配置可用的课程下拉步骤。"); + } + + var items = new List(); + foreach (var node in jsonPathService.ResolveArray(responseJson, mapping.ItemsPath)) + { + var label = jsonPathService.ResolveString(node, mapping.LabelPath)?.Trim(); + var value = jsonPathService.ResolveString(node, mapping.ValuePath)?.Trim(); + if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + items.Add(new CourseOptionDto(value, label)); + } + + return items; + } + + public async Task ReadCatalogAsync( + PlatformDefinition platform, + WorkflowExecutionState state, + CancellationToken cancellationToken) + { + JsonNode? responseJson = null; + CatalogMappingDto? mapping = null; + + foreach (var step in GetSteps(platform, PlatformWorkflowScope.Catalog)) + { + var result = await ExecuteStepAsync(step, state, cancellationToken); + var stepMapping = Deserialize(step.CatalogMappingJson); + if (stepMapping is not null) + { + responseJson = result.ResponseJson; + mapping = stepMapping; + } + } + + if (mapping is null || responseJson is null) + { + throw new PlatformOperationException("平台未配置可用的章节目录步骤。"); + } + + var chapters = new List(); + foreach (var chapterNode in jsonPathService.ResolveArray(responseJson, mapping.ChaptersPath)) + { + var sections = new List(); + foreach (var sectionNode in jsonPathService.ResolveArray(chapterNode, mapping.SectionsPath)) + { + sections.Add( + new CatalogSectionDto( + jsonPathService.ResolveString(sectionNode, mapping.SectionIdPath) ?? string.Empty, + jsonPathService.ResolveString(sectionNode, mapping.SectionNumberPath) ?? string.Empty, + jsonPathService.ResolveString(sectionNode, mapping.SectionNamePath) ?? string.Empty, + jsonPathService.ResolveBoolean(sectionNode, mapping.SectionFinishedPath), + jsonPathService.ResolveBoolean(sectionNode, mapping.SectionLearningPath), + jsonPathService.ResolveString(sectionNode, mapping.SectionTaskIdPath) ?? string.Empty)); + } + + chapters.Add( + new CatalogChapterDto( + jsonPathService.ResolveString(chapterNode, mapping.ChapterIdPath) ?? string.Empty, + jsonPathService.ResolveString(chapterNode, mapping.ChapterNumberPath) ?? string.Empty, + jsonPathService.ResolveString(chapterNode, mapping.ChapterNamePath) ?? string.Empty, + jsonPathService.ResolveBoolean(chapterNode, mapping.ChapterFinishedPath), + jsonPathService.ResolveBoolean(chapterNode, mapping.ChapterLearningPath), + sections)); + } + + return new CatalogResponse( + state.ContextValues["courseId"], + chapters, + false, + "upstream", + null); + } + + public async Task ReadUnitsAsync( + PlatformDefinition platform, + WorkflowExecutionState state, + CancellationToken cancellationToken) + { + JsonNode? responseJson = null; + UnitMappingDto? mapping = null; + + foreach (var step in GetSteps(platform, PlatformWorkflowScope.Units)) + { + var result = await ExecuteStepAsync(step, state, cancellationToken); + var stepMapping = Deserialize(step.UnitMappingJson); + if (stepMapping is not null) + { + responseJson = result.ResponseJson; + mapping = stepMapping; + } + } + + if (mapping is null || responseJson is null) + { + throw new PlatformOperationException("平台未配置可用的资源读取步骤。"); + } + + var items = new List(); + foreach (var itemNode in jsonPathService.ResolveArray(responseJson, mapping.ItemsPath)) + { + var primarySourceUrl = jsonPathService.ResolveString(itemNode, mapping.VideoSourcePath); + var primarySourceName = jsonPathService.ResolveString(itemNode, mapping.VideoSourceNamePath); + items.Add( + new UnitItemDto( + jsonPathService.ResolveString(itemNode, mapping.ItemIdPath) ?? string.Empty, + jsonPathService.ResolveString(itemNode, mapping.ItemTitlePath) ?? string.Empty, + jsonPathService.ResolveString(itemNode, mapping.ItemTypePath) ?? string.Empty, + jsonPathService.ResolveBoolean(itemNode, mapping.ItemFinishedPath), + !string.IsNullOrWhiteSpace(primarySourceUrl), + jsonPathService.ResolveDouble(itemNode, mapping.VideoPositionPath), + ResolveNullableDouble(itemNode, mapping.VideoLengthPath), + string.IsNullOrWhiteSpace(primarySourceName) ? null : primarySourceName, + string.IsNullOrWhiteSpace(primarySourceUrl) ? null : primarySourceUrl, + jsonPathService.ResolveInt(itemNode, mapping.DocumentCountPath), + [], + "")); + } + + return new UnitsResponse( + state.ContextValues["courseId"], + state.ContextValues["chapterId"], + state.ContextValues["sectionId"], + items, + false, + "upstream", + null); + } + + private async Task ExecuteStepAsync( + PlatformWorkflowStep step, + WorkflowExecutionState state, + CancellationToken cancellationToken) + { + switch (step.StepType) + { + case PlatformWorkflowStepType.SessionPassthrough: + ApplyPassthroughStep(step, state); + return new StepExecutionResult(null); + case PlatformWorkflowStepType.HttpRequest: + return await ExecuteHttpRequestStepAsync(step, state, cancellationToken); + default: + throw new PlatformOperationException("当前作用域不支持浏览器挑战步骤。"); + } + } + + private async Task ExecuteHttpRequestStepAsync( + PlatformWorkflowStep step, + WorkflowExecutionState state, + CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient("platform-workflow"); + var requestUrl = BuildRequestUrl(step, state); + using var request = new HttpRequestMessage(new HttpMethod(step.HttpMethod), requestUrl); + + foreach (var header in ResolveStringMap(step.HeadersTemplateJson, state)) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + if (!request.Headers.Contains("Cookie") && state.SessionData.Cookies.Count > 0) + { + request.Headers.TryAddWithoutValidation( + "Cookie", + string.Join("; ", state.SessionData.Cookies.Select(item => $"{item.Key}={item.Value}"))); + } + + var bodyNode = ResolveJsonNode(step.BodyTemplateJson, state); + if (bodyNode is not null) + { + if (string.Equals(step.ContentType, "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + request.Content = new FormUrlEncodedContent(ResolveFormValues(bodyNode)); + } + else + { + request.Content = new StringContent( + bodyNode.ToJsonString(), + Encoding.UTF8, + string.IsNullOrWhiteSpace(step.ContentType) ? "application/json" : step.ContentType); + } + } + + using var response = await client.SendAsync(request, cancellationToken); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + throw new PlatformOperationException("平台会话已失效或登录已过期。", true); + } + + if (!response.IsSuccessStatusCode) + { + throw new PlatformOperationException($"平台接口返回了 {(int)response.StatusCode}。"); + } + + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + var responseJson = string.IsNullOrWhiteSpace(responseText) + ? new JsonObject() + : JsonNode.Parse(responseText); + + if (!string.IsNullOrWhiteSpace(step.SuccessPath)) + { + var actual = jsonPathService.ResolveString(responseJson, step.SuccessPath); + if (!MatchesExpected(step.SuccessExpectedValue, actual)) + { + if (LooksUnauthorized(actual)) + { + throw new PlatformOperationException(actual ?? "平台会话已失效。", true); + } + + throw new PlatformOperationException(actual ?? $"{step.DisplayName} 未通过成功判定。"); + } + } + + var responseHeaders = response.Headers + .Concat(response.Content.Headers) + .ToDictionary( + item => item.Key, + item => string.Join(", ", item.Value), + StringComparer.OrdinalIgnoreCase); + + var responseCookies = ExtractCookies(response); + foreach (var cookie in responseCookies) + { + state.SessionData.Cookies[cookie.Key] = cookie.Value; + } + + var stepOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var mapping in DeserializeCookieMappings(step.OutputCookiesJson)) + { + var value = ResolveExpression(mapping.Expression, state, responseJson, responseCookies, responseHeaders); + if (!string.IsNullOrWhiteSpace(value)) + { + state.SessionData.Cookies[mapping.Name] = value; + stepOutputs[mapping.Name] = value; + } + } + + foreach (var mapping in DeserializeVariableMappings(step.OutputVariablesJson)) + { + var value = ResolveExpression(mapping.Key == string.Empty ? string.Empty : mapping.Expression, state, responseJson, responseCookies, responseHeaders); + if (!string.IsNullOrWhiteSpace(value)) + { + state.SessionData.Outputs[mapping.Key] = value; + stepOutputs[mapping.Key] = value; + } + } + + if (stepOutputs.Count > 0) + { + state.SessionData.StepOutputs[step.StepKey] = stepOutputs; + } + + state.StepJson[step.StepKey] = responseJson; + state.ResponseHeaders[step.StepKey] = responseHeaders; + return new StepExecutionResult(responseJson); + } + + private void ApplyPassthroughStep(PlatformWorkflowStep step, WorkflowExecutionState state) + { + var stepOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var mapping in DeserializeCookieMappings(step.OutputCookiesJson)) + { + var value = ResolveExpression(mapping.Expression, state, null, new Dictionary(), new Dictionary()); + if (!string.IsNullOrWhiteSpace(value)) + { + state.SessionData.Cookies[mapping.Name] = value; + stepOutputs[mapping.Name] = value; + } + } + + foreach (var mapping in DeserializeVariableMappings(step.OutputVariablesJson)) + { + var value = ResolveExpression(mapping.Expression, state, null, new Dictionary(), new Dictionary()); + if (!string.IsNullOrWhiteSpace(value)) + { + state.SessionData.Outputs[mapping.Key] = value; + stepOutputs[mapping.Key] = value; + } + } + + if (stepOutputs.Count > 0) + { + state.SessionData.StepOutputs[step.StepKey] = stepOutputs; + } + } + + private Uri BuildRequestUrl(PlatformWorkflowStep step, WorkflowExecutionState state) + { + var resolvedUrl = ResolveTemplate(step.UrlTemplate, state); + if (string.IsNullOrWhiteSpace(resolvedUrl)) + { + throw new PlatformOperationException($"{step.DisplayName} 未配置请求地址。"); + } + + if (!Uri.TryCreate(resolvedUrl, UriKind.Absolute, out var uri)) + { + uri = new Uri(new Uri(uoocOptions.Value.BaseUrl.TrimEnd('/') + "/"), resolvedUrl.TrimStart('/')); + } + + var query = ResolveStringMap(step.QueryTemplateJson, state); + if (query.Count == 0) + { + return uri; + } + + var builder = new UriBuilder(uri); + var queryString = string.Join( + "&", + query + .Where(item => !string.IsNullOrWhiteSpace(item.Value)) + .Select(item => $"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value)}")); + + if (string.IsNullOrWhiteSpace(queryString)) + { + return uri; + } + + builder.Query = queryString; + return builder.Uri; + } + + private Dictionary ResolveStringMap(string? json, WorkflowExecutionState state) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var node = JsonNode.Parse(json) as JsonObject + ?? throw new PlatformOperationException("请求模板 JSON 格式无效。"); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in node) + { + if (property.Value is null) + { + continue; + } + + resolved[property.Key] = ResolveNodeToString(property.Value, state); + } + + return resolved; + } + + private JsonNode? ResolveJsonNode(string? json, WorkflowExecutionState state) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + JsonNode node; + try + { + node = JsonNode.Parse(json) ?? JsonValue.Create(string.Empty)!; + } + catch + { + return JsonValue.Create(ResolveTemplate(json, state)); + } + + return ResolveJsonNode(node, state); + } + + private JsonNode ResolveJsonNode(JsonNode node, WorkflowExecutionState state) + { + return node switch + { + JsonObject jsonObject => ResolveObject(jsonObject, state), + JsonArray jsonArray => ResolveArray(jsonArray, state), + JsonValue jsonValue => ResolveValue(jsonValue, state), + _ => node.DeepClone() + }; + } + + private JsonObject ResolveObject(JsonObject source, WorkflowExecutionState state) + { + var target = new JsonObject(); + foreach (var property in source) + { + target[property.Key] = property.Value is null ? null : ResolveJsonNode(property.Value, state); + } + + return target; + } + + private JsonArray ResolveArray(JsonArray source, WorkflowExecutionState state) + { + var target = new JsonArray(); + foreach (var item in source) + { + target.Add(item is null ? null : ResolveJsonNode(item, state)); + } + + return target; + } + + private JsonNode ResolveValue(JsonValue value, WorkflowExecutionState state) + { + if (value.TryGetValue(out var stringValue)) + { + return JsonValue.Create(ResolveTemplate(stringValue, state))!; + } + + return value.DeepClone(); + } + + private string ResolveNodeToString(JsonNode node, WorkflowExecutionState state) + { + if (node is JsonValue jsonValue) + { + if (jsonValue.TryGetValue(out var stringValue)) + { + return ResolveTemplate(stringValue, state); + } + + return node.ToJsonString().Trim('"'); + } + + return node.ToJsonString(); + } + + private IReadOnlyDictionary BuildContext(IReadOnlyDictionary source) + { + var context = new Dictionary(source, StringComparer.OrdinalIgnoreCase); + context.TryAdd("uoocBaseUrl", uoocOptions.Value.BaseUrl.TrimEnd('/')); + return context; + } + + private string ResolveExpression( + string expression, + WorkflowExecutionState state, + JsonNode? responseJson, + IReadOnlyDictionary responseCookies, + IReadOnlyDictionary responseHeaders) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return string.Empty; + } + + if (expression.Contains("{{", StringComparison.Ordinal)) + { + return ResolveTemplate(expression, state); + } + + if (expression.StartsWith("cookie:", StringComparison.OrdinalIgnoreCase)) + { + var cookieName = expression["cookie:".Length..]; + return responseCookies.TryGetValue(cookieName, out var cookieValue) ? cookieValue : string.Empty; + } + + if (expression.StartsWith("header:", StringComparison.OrdinalIgnoreCase)) + { + var headerName = expression["header:".Length..]; + return responseHeaders.TryGetValue(headerName, out var headerValue) ? headerValue : string.Empty; + } + + if (expression.StartsWith("$", StringComparison.Ordinal)) + { + return jsonPathService.ResolveString(responseJson, expression) ?? string.Empty; + } + + if (expression.StartsWith("field.", StringComparison.OrdinalIgnoreCase) + || expression.StartsWith("context.", StringComparison.OrdinalIgnoreCase) + || expression.StartsWith("connection.", StringComparison.OrdinalIgnoreCase) + || expression.StartsWith("step.", StringComparison.OrdinalIgnoreCase)) + { + return ResolveTemplate($"{{{{{expression}}}}}", state); + } + + return expression; + } + + private static bool MatchesExpected(string? expected, string? actual) + { + if (string.IsNullOrWhiteSpace(expected)) + { + return !string.IsNullOrWhiteSpace(actual) + && !string.Equals(actual, "0", StringComparison.OrdinalIgnoreCase) + && !string.Equals(actual, "false", StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(actual?.Trim(), expected.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksUnauthorized(string? message) => + !string.IsNullOrWhiteSpace(message) + && (message.Contains("登录", StringComparison.OrdinalIgnoreCase) + || message.Contains("未登录", StringComparison.OrdinalIgnoreCase) + || message.Contains("auth", StringComparison.OrdinalIgnoreCase) + || message.Contains("expired", StringComparison.OrdinalIgnoreCase)); + + private static Dictionary ExtractCookies(HttpResponseMessage response) + { + var cookies = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!response.Headers.TryGetValues("Set-Cookie", out var values)) + { + return cookies; + } + + foreach (var raw in values) + { + var firstPart = raw.Split(';', 2)[0]; + var separator = firstPart.IndexOf('='); + if (separator <= 0) + { + continue; + } + + var name = firstPart[..separator].Trim(); + var value = firstPart[(separator + 1)..].Trim(); + if (!string.IsNullOrWhiteSpace(name)) + { + cookies[name] = value; + } + } + + return cookies; + } + + private static IEnumerable> ResolveFormValues(JsonNode bodyNode) + { + if (bodyNode is not JsonObject bodyObject) + { + return []; + } + + return bodyObject + .Where(item => item.Value is not null) + .Select(item => new KeyValuePair(item.Key, item.Value!.ToJsonString().Trim('"'))); + } + + private static double? ResolveNullableDouble(JsonNode? node, string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var service = new SimpleJsonPathService(); + var resolved = service.ResolveString(node, path); + return double.TryParse(resolved, out var value) ? value : null; + } + + private static IReadOnlyList DeserializeCookieMappings(string? json) => + DeserializeList(json); + + private static IReadOnlyList DeserializeVariableMappings(string? json) => + DeserializeList(json); + + private static IReadOnlyList DeserializeList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch + { + return []; + } + } + + private static T? Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return default; + } + } + + private sealed record StepExecutionResult(JsonNode? ResponseJson); +} diff --git a/backend/src/UoocProgress.Api/Services/RuntimeModels.cs b/backend/src/UoocProgress.Api/Services/RuntimeModels.cs new file mode 100644 index 0000000..a242a2b --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/RuntimeModels.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Nodes; + +namespace UoocProgress.Api.Services; + +public sealed class PlatformSessionData +{ + public Dictionary Cookies { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary> StepOutputs { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class WorkflowExecutionState +{ + public Dictionary InputFields { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + public PlatformSessionData SessionData { get; init; } = new(); + + public Dictionary ContextValues { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary StepJson { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary> ResponseHeaders { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed record WorkflowStepResult( + bool Continue, + string Message, + string? ChallengeSessionId = null, + string? ChallengeUrl = null); + +public sealed class PlatformOperationException : Exception +{ + public PlatformOperationException(string message, bool isUnauthorized = false) + : base(message) + { + IsUnauthorized = isUnauthorized; + } + + public bool IsUnauthorized { get; } +} diff --git a/backend/src/UoocProgress.Api/Services/SecretProtectionService.cs b/backend/src/UoocProgress.Api/Services/SecretProtectionService.cs new file mode 100644 index 0000000..54b82bf --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/SecretProtectionService.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; + +namespace UoocProgress.Api.Services; + +public sealed class SecretProtectionService(IDataProtectionProvider dataProtectionProvider) +{ + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("uooc-progress.platform-secrets.v1"); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public string ProtectDictionary(IReadOnlyDictionary values) => + _protector.Protect(JsonSerializer.Serialize(values, JsonOptions)); + + public Dictionary UnprotectDictionary(string? protectedValue) + { + if (string.IsNullOrWhiteSpace(protectedValue)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + try + { + return JsonSerializer.Deserialize>( + _protector.Unprotect(protectedValue), + JsonOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + public string ProtectSessionData(PlatformSessionData data) => + _protector.Protect(JsonSerializer.Serialize(data, JsonOptions)); + + public PlatformSessionData UnprotectSessionData(string? protectedValue) + { + if (string.IsNullOrWhiteSpace(protectedValue)) + { + return new PlatformSessionData(); + } + + try + { + return JsonSerializer.Deserialize( + _protector.Unprotect(protectedValue), + JsonOptions) + ?? new PlatformSessionData(); + } + catch + { + return new PlatformSessionData(); + } + } +} diff --git a/backend/src/UoocProgress.Api/Services/SimpleJsonPathService.cs b/backend/src/UoocProgress.Api/Services/SimpleJsonPathService.cs new file mode 100644 index 0000000..7a14cd1 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/SimpleJsonPathService.cs @@ -0,0 +1,179 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Text.Json.Nodes; + +namespace UoocProgress.Api.Services; + +public sealed class SimpleJsonPathService +{ + private static readonly Regex IndexedSegmentRegex = new( + "^(?[^\\[]+)(\\[(?\\d+)\\])?$", + RegexOptions.Compiled); + + public JsonNode? Resolve(JsonNode? node, string? path) + { + if (node is null || string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var normalized = path.Trim(); + if (normalized.StartsWith("$.", StringComparison.Ordinal)) + { + normalized = normalized[2..]; + } + else if (normalized.StartsWith('$')) + { + normalized = normalized[1..]; + } + + if (string.IsNullOrWhiteSpace(normalized)) + { + return node; + } + + JsonNode? current = node; + foreach (var rawSegment in normalized.Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + if (current is null) + { + return null; + } + + var segment = rawSegment.Trim(); + if (segment.EndsWith("[]", StringComparison.Ordinal)) + { + var propertyName = segment[..^2]; + current = propertyName.Length == 0 ? current : current[propertyName]; + return current; + } + + if (segment == "length") + { + return current is JsonArray array ? JsonValue.Create(array.Count) : null; + } + + if (int.TryParse(segment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericIndex)) + { + current = current is JsonArray directArray && numericIndex >= 0 && numericIndex < directArray.Count + ? directArray[numericIndex] + : null; + continue; + } + + var match = IndexedSegmentRegex.Match(segment); + if (!match.Success) + { + current = current[segment]; + continue; + } + + var property = match.Groups["name"].Value; + current = property.Length == 0 ? current : current[property]; + + if (match.Groups["index"].Success) + { + var index = int.Parse(match.Groups["index"].Value, CultureInfo.InvariantCulture); + current = current is JsonArray array && index >= 0 && index < array.Count + ? array[index] + : null; + } + } + + return current; + } + + public string? ResolveString(JsonNode? node, string? path) + { + var resolved = Resolve(node, path); + if (resolved is null) + { + return null; + } + + if (resolved is JsonValue value) + { + if (value.TryGetValue(out var stringValue)) + { + return stringValue; + } + + if (value.TryGetValue(out var boolValue)) + { + return boolValue ? "true" : "false"; + } + + if (value.TryGetValue(out var intValue)) + { + return intValue.ToString(CultureInfo.InvariantCulture); + } + + if (value.TryGetValue(out var longValue)) + { + return longValue.ToString(CultureInfo.InvariantCulture); + } + + if (value.TryGetValue(out var doubleValue)) + { + return doubleValue.ToString(CultureInfo.InvariantCulture); + } + } + + if (resolved is JsonArray array) + { + return array.Count.ToString(CultureInfo.InvariantCulture); + } + + return resolved.ToJsonString(); + } + + public bool ResolveBoolean(JsonNode? node, string? path) + { + var resolved = ResolveString(node, path); + if (string.IsNullOrWhiteSpace(resolved)) + { + return false; + } + + if (bool.TryParse(resolved, out var boolValue)) + { + return boolValue; + } + + if (int.TryParse(resolved, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + return intValue != 0; + } + + return string.Equals(resolved, "yes", StringComparison.OrdinalIgnoreCase) + || string.Equals(resolved, "ok", StringComparison.OrdinalIgnoreCase); + } + + public double ResolveDouble(JsonNode? node, string? path) + { + var resolved = ResolveString(node, path); + return double.TryParse(resolved, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) + ? result + : 0; + } + + public int ResolveInt(JsonNode? node, string? path) + { + var resolvedNode = Resolve(node, path); + if (resolvedNode is JsonArray array) + { + return array.Count; + } + + var resolved = ResolveString(node, path); + return int.TryParse(resolved, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) + ? result + : 0; + } + + public JsonArray ResolveArray(JsonNode? node, string? path) + { + var resolved = Resolve(node, path); + return resolved as JsonArray ?? []; + } +} diff --git a/backend/src/UoocProgress.Api/Services/SystemSettingsService.cs b/backend/src/UoocProgress.Api/Services/SystemSettingsService.cs new file mode 100644 index 0000000..ab7a289 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/SystemSettingsService.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; + +namespace UoocProgress.Api.Services; + +public sealed class SystemSettingsService(AppDbContext dbContext) +{ + public async Task GetEntityAsync(CancellationToken cancellationToken = default) + { + var settings = await dbContext.SystemSettings.SingleOrDefaultAsync(cancellationToken); + if (settings is not null) + { + return settings; + } + + settings = new SystemSettingRecord + { + RegistrationMode = RegistrationMode.Open, + AllowMockFallback = false, + BrowserChallengeTimeoutSeconds = 600, + ConnectionEncryptionVersion = 1, + DefaultPlatformVisibility = "all_active", + UpdatedAt = DateTimeOffset.UtcNow + }; + + dbContext.SystemSettings.Add(settings); + await dbContext.SaveChangesAsync(cancellationToken); + return settings; + } + + public async Task GetDtoAsync(CancellationToken cancellationToken = default) => + (await GetEntityAsync(cancellationToken)).ToDto(); + + public async Task GetAllowMockFallbackAsync(CancellationToken cancellationToken = default) => + (await GetEntityAsync(cancellationToken)).AllowMockFallback; + + public async Task UpdateAsync( + UpdateSystemSettingRequest request, + CancellationToken cancellationToken = default) + { + if (!EnumValueCodec.TryParseRegistrationMode(request.RegistrationMode, out var mode)) + { + throw new InvalidOperationException("registrationMode 仅支持 open 或 invite_only。"); + } + + var settings = await GetEntityAsync(cancellationToken); + settings.SystemName = string.IsNullOrWhiteSpace(request.SystemName) + ? "UOOC Progress" + : request.SystemName.Trim(); + settings.RegistrationMode = mode; + settings.AllowMockFallback = request.AllowMockFallback; + settings.BrowserChallengeTimeoutSeconds = Math.Max(30, request.BrowserChallengeTimeoutSeconds); + settings.ConnectionEncryptionVersion = Math.Max(1, request.ConnectionEncryptionVersion); + settings.DefaultPlatformVisibility = string.IsNullOrWhiteSpace(request.DefaultPlatformVisibility) + ? "all_active" + : request.DefaultPlatformVisibility.Trim(); + settings.RequireEmailVerification = request.RequireEmailVerification; + settings.SmtpHost = string.IsNullOrWhiteSpace(request.SmtpHost) ? null : request.SmtpHost.Trim(); + settings.SmtpPort = request.SmtpPort <= 0 ? 587 : request.SmtpPort; + settings.SmtpUseSsl = request.SmtpUseSsl; + settings.SmtpUsername = string.IsNullOrWhiteSpace(request.SmtpUsername) ? null : request.SmtpUsername.Trim(); + // Only overwrite password when a new non-empty value is provided + if (!string.IsNullOrEmpty(request.SmtpPassword)) + { + settings.SmtpPassword = request.SmtpPassword; + } + settings.SmtpFromEmail = string.IsNullOrWhiteSpace(request.SmtpFromEmail) ? null : request.SmtpFromEmail.Trim(); + settings.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + return settings.ToDto(); + } +} diff --git a/backend/src/UoocProgress.Api/Services/TemplateResolver.cs b/backend/src/UoocProgress.Api/Services/TemplateResolver.cs new file mode 100644 index 0000000..1223192 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/TemplateResolver.cs @@ -0,0 +1,91 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace UoocProgress.Api.Services; + +public sealed class TemplateResolver +{ + private static readonly Regex TokenRegex = new(@"\{\{\s*(?[^}]+)\s*\}\}", RegexOptions.Compiled); + + public string Resolve( + string? template, + IReadOnlyDictionary fields, + PlatformSessionData sessionData, + IReadOnlyDictionary context, + IReadOnlyDictionary> stepOutputs) + { + if (string.IsNullOrWhiteSpace(template)) + { + return string.Empty; + } + + return TokenRegex.Replace( + template, + match => ResolveToken(match.Groups["token"].Value.Trim(), fields, sessionData, context, stepOutputs)); + } + + private static string ResolveToken( + string token, + IReadOnlyDictionary fields, + PlatformSessionData sessionData, + IReadOnlyDictionary context, + IReadOnlyDictionary> stepOutputs) + { + var encodeBase64 = false; + + if (token.StartsWith("base64:", StringComparison.OrdinalIgnoreCase)) + { + encodeBase64 = true; + token = token["base64:".Length..]; + } + + var result = ResolveTokenCore(token, fields, sessionData, context, stepOutputs); + + return encodeBase64 ? Convert.ToBase64String(Encoding.UTF8.GetBytes(result)) : result; + } + + private static string ResolveTokenCore( + string token, + IReadOnlyDictionary fields, + PlatformSessionData sessionData, + IReadOnlyDictionary context, + IReadOnlyDictionary> stepOutputs) + { + if (token.StartsWith("field.", StringComparison.OrdinalIgnoreCase)) + { + var key = token["field.".Length..]; + return fields.TryGetValue(key, out var value) ? value : string.Empty; + } + + if (token.StartsWith("context.", StringComparison.OrdinalIgnoreCase)) + { + var key = token["context.".Length..]; + return context.TryGetValue(key, out var value) ? value : string.Empty; + } + + if (token.StartsWith("connection.cookie.", StringComparison.OrdinalIgnoreCase)) + { + var key = token["connection.cookie.".Length..]; + return sessionData.Cookies.TryGetValue(key, out var value) ? value : string.Empty; + } + + if (token.StartsWith("connection.output.", StringComparison.OrdinalIgnoreCase)) + { + var key = token["connection.output.".Length..]; + return sessionData.Outputs.TryGetValue(key, out var value) ? value : string.Empty; + } + + if (token.StartsWith("step.", StringComparison.OrdinalIgnoreCase)) + { + var parts = token.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 3 + && stepOutputs.TryGetValue(parts[1], out var values) + && values.TryGetValue(parts[2], out var value)) + { + return value; + } + } + + return string.Empty; + } +} diff --git a/backend/src/UoocProgress.Api/Services/UoocApiService.cs b/backend/src/UoocProgress.Api/Services/UoocApiService.cs new file mode 100644 index 0000000..1287c1d --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/UoocApiService.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; + +namespace UoocProgress.Api.Services; + +/// +/// Direct UOOC API calls with hardcoded JSON paths. +/// Bypasses the generic database-configured workflow for known UOOC endpoints. +/// +public sealed class UoocApiService( + IHttpClientFactory httpClientFactory, + IOptions uoocOptions) +{ + public async Task> GetCoursesAsync( + PlatformSessionData sessionData, + string? keyword, + string? page, + CancellationToken cancellationToken) + { + var client = CreateClient(sessionData); + var query = $"keyword={Uri.EscapeDataString(keyword ?? "")}&page={Uri.EscapeDataString(page ?? "1")}&type=learn"; + var url = $"{uoocOptions.Value.BaseUrl.TrimEnd('/')}/home/course/list?{query}"; + + var response = await client.GetAsync(url, cancellationToken); + var json = await ReadJson(response, cancellationToken); + + var items = new List(); + foreach (var node in ResolveArray(json, "$.data.data")) + { + var label = ResolveString(node, "parent_name")?.Trim(); + var value = ResolveString(node, "id")?.Trim(); + if (!string.IsNullOrWhiteSpace(label) && !string.IsNullOrWhiteSpace(value)) + items.Add(new CourseOptionDto(value, label)); + } + + return items; + } + + public async Task GetCatalogAsync( + PlatformSessionData sessionData, + string courseId, + CancellationToken cancellationToken) + { + var client = CreateClient(sessionData); + var url = $"{uoocOptions.Value.BaseUrl.TrimEnd('/')}/home/learn/getCatalogList?cid={Uri.EscapeDataString(courseId)}&hidemsg_=true&show="; + + var response = await client.GetAsync(url, cancellationToken); + var json = await ReadJson(response, cancellationToken); + + var chapters = new List(); + foreach (var chNode in ResolveArray(json, "$.data")) + { + var sections = new List(); + foreach (var secNode in ResolveArray(chNode, "children")) + { + sections.Add(new CatalogSectionDto( + ResolveString(secNode, "id") ?? "", + "", + ResolveString(secNode, "name") ?? "", + ResolveBool(secNode, "finished"), + ResolveBool(secNode, "learning"), + ResolveString(secNode, "task_id") ?? "")); + } + + chapters.Add(new CatalogChapterDto( + ResolveString(chNode, "id") ?? "", + "", + ResolveString(chNode, "name") ?? "", + ResolveBool(chNode, "finished"), + ResolveBool(chNode, "learning"), + sections)); + } + + return new CatalogResponse(courseId, chapters, false, "upstream", null); + } + + public async Task GetUnitsAsync( + PlatformSessionData sessionData, + string courseId, + string chapterId, + string sectionId, + CancellationToken cancellationToken) + { + var client = CreateClient(sessionData); + var url = $"{uoocOptions.Value.BaseUrl.TrimEnd('/')}/home/learn/getUnitLearn" + + $"?catalog_id={Uri.EscapeDataString(sectionId)}" + + $"&chapter_id={Uri.EscapeDataString(chapterId)}" + + $"&cid={Uri.EscapeDataString(courseId)}" + + $"&hidemsg_=true" + + $"§ion_id={Uri.EscapeDataString(sectionId)}" + + $"&show="; + + var response = await client.GetAsync(url, cancellationToken); + var json = await ReadJson(response, cancellationToken); + + var items = new List(); + foreach (var node in ResolveArray(json, "$.data")) + { + var sourceUrl = ResolveString(node, "video_play_list[0].source"); + var sourceName = ResolveString(node, "video_play_list[0].source_name"); + + // Extract all video sources + var videoSources = new List(); + foreach (var vs in ResolveArray(node, "video_play_list")) + { + var src = ResolveString(vs, "source"); + var name = ResolveString(vs, "source_name"); + if (!string.IsNullOrWhiteSpace(src)) + videoSources.Add(new VideoSourceDto(src, name ?? "")); + } + + items.Add(new UnitItemDto( + ResolveString(node, "id") ?? "", + ResolveString(node, "title") ?? "", + ResolveString(node, "type") ?? "", + ResolveBool(node, "finished"), + !string.IsNullOrWhiteSpace(sourceUrl), + ResolveDouble(node, "video_pos"), + null, + string.IsNullOrWhiteSpace(sourceName) ? null : sourceName, + string.IsNullOrWhiteSpace(sourceUrl) ? null : sourceUrl, + 0, + videoSources, + ResolveString(node, "catalog_id") ?? "")); + } + + return new UnitsResponse(courseId, chapterId, sectionId, items, false, "upstream", null); + } + + private HttpClient CreateClient(PlatformSessionData sessionData) + { + var client = httpClientFactory.CreateClient("platform-workflow"); + if (sessionData.Cookies.TryGetValue("uooc_auth", out var cookie)) + client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", $"uooc_auth={cookie}"); + return client; + } + + private async Task ReadJson(HttpResponseMessage response, CancellationToken ct) + { + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + throw new PlatformOperationException("UOOC 会话已过期,请重新登录。", true); + + if (!response.IsSuccessStatusCode) + throw new PlatformOperationException($"UOOC 接口返回 {(int)response.StatusCode}。"); + + var text = await response.Content.ReadAsStringAsync(ct); + var json = JsonNode.Parse(text) ?? new JsonObject(); + + var code = ResolveInt(json, "$.code"); + if (code != 1) + { + var msg = ResolveString(json, "$.msg") ?? "UOOC 接口返回错误。"; + throw new PlatformOperationException(msg); + } + + return json; + } + + private static JsonArray ResolveArray(JsonNode? node, string path) + { + var current = Walk(node, path); + return current as JsonArray ?? []; + } + + private static string? ResolveString(JsonNode? node, string path) + { + var current = Walk(node, path); + if (current is JsonValue v) + { + if (v.TryGetValue(out var s)) return s; + return v.ToJsonString().Trim('"'); + } + return current?.ToJsonString(); + } + + private static bool ResolveBool(JsonNode? node, string path) + { + var s = ResolveString(node, path); + return s is "1" or "true" or "True" or "yes"; + } + + private static double ResolveDouble(JsonNode? node, string path) + { + var s = ResolveString(node, path); + return double.TryParse(s, out var v) ? v : 0; + } + + private static int ResolveInt(JsonNode? node, string path) + { + var s = ResolveString(node, path); + return int.TryParse(s, out var v) ? v : 0; + } + + private static JsonNode? Walk(JsonNode? node, string path) + { + if (node is null || string.IsNullOrWhiteSpace(path)) return node; + var segments = path.TrimStart('$').TrimStart('.').Split('.', StringSplitOptions.RemoveEmptyEntries); + JsonNode? current = node; + foreach (var seg in segments) + { + if (current is null) return null; + var bracketIdx = seg.IndexOf('['); + if (bracketIdx > 0) + { + var propName = seg[..bracketIdx]; + var idxStr = seg[(bracketIdx + 1)..].TrimEnd(']'); + current = current[propName]; + if (current is JsonArray arr && int.TryParse(idxStr, out var idx) && idx >= 0 && idx < arr.Count) + current = arr[idx]; + } + else + { + current = current[seg]; + } + } + return current; + } +} diff --git a/backend/src/UoocProgress.Api/Services/VideoBrushService.cs b/backend/src/UoocProgress.Api/Services/VideoBrushService.cs new file mode 100644 index 0000000..2333b3d --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/VideoBrushService.cs @@ -0,0 +1,1048 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using UoocProgress.Api.Data; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; + +namespace UoocProgress.Api.Services; + +public sealed class BrushSystemConfig +{ + public bool PauseNewTasks { get; set; } +} + +public sealed class VideoBrushService( + IHttpClientFactory httpClientFactory, + IOptions uoocOptions, + UoocApiService uoocApiService, + ZhihuishuApiService zhihuishuApiService, + IServiceScopeFactory scopeFactory) +{ + // userId → ordered task list (first Running/Queued task is the "current" one) + private static readonly ConcurrentDictionary> _userTasks = new(); + private static readonly object _queueLock = new(); + + // ── Database Persistence ──────────────────────────────── + + private AppDbContext GetDb() + { + var scope = scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + private SecretProtectionService GetSecretProtection() + { + var scope = scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + public void RestorePersistedTasks() + { + using var db = GetDb(); + var records = db.BrushTasks.ToList(); + foreach (var r in records) + { + try + { + var state = RecordToState(r); + if (state is null) continue; + + if (state.Status == BrushRunStatus.Running || state.Status == BrushRunStatus.Queued) + { + Console.WriteLine($"[Brush] Restoring task #{r.Id} (was {r.Status}) for user {r.UserId}"); + state.RecordId = r.Id; + state.Status = BrushRunStatus.Queued; + state.Cancelled = false; + state.RetryCount = 0; + state.LastError = null; + } + else + { + Console.WriteLine($"[Brush] Restoring finished task #{r.Id} ({r.Status}) for user {r.UserId}"); + state.RecordId = r.Id; + } + + lock (_queueLock) + { + var list = _userTasks.GetOrAdd(state.UserId, _ => []); + list.Add(state); + } + + TryStartNextTask(state.UserId); + } + catch (Exception ex) + { + Console.WriteLine($"[Brush] Failed to restore task #{r.Id}: {ex.Message}"); + } + } + } + + private void SaveToDb(UserBrushState state) + { + try + { + using var db = GetDb(); + var record = StateToRecord(state); + record.UpdatedAt = DateTimeOffset.UtcNow; + + if (state.RecordId > 0) + { + record.Id = state.RecordId; + db.BrushTasks.Update(record); + } + else + { + record.CreatedAt = DateTimeOffset.UtcNow; + db.BrushTasks.Add(record); + } + db.SaveChanges(); + state.RecordId = record.Id; + } + catch (Exception ex) + { + Console.WriteLine($"[Brush] DB save failed: {ex.Message}"); + } + } + + private void DeleteFromDb(UserBrushState state) + { + if (state.RecordId <= 0) return; + try + { + using var db = GetDb(); + var record = db.BrushTasks.Find(state.RecordId); + if (record is not null) + { + db.BrushTasks.Remove(record); + db.SaveChanges(); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Brush] DB delete failed: {ex.Message}"); + } + } + + private BrushTaskRecord StateToRecord(UserBrushState state) + { + return new BrushTaskRecord + { + UserId = state.UserId, + PlatformSlug = state.PlatformSlug, + CourseId = state.CourseId, + Status = state.Status.ToString(), + ChaptersJson = JsonSerializer.Serialize(state.Chapters), + EncryptedSessionData = GetSecretProtection().ProtectSessionData(state.SessionData), + TotalVideos = state.TotalVideos, + CompletedVideos = state.CompletedVideos, + CurrentChapterName = state.CurrentChapterName, + CurrentSectionName = state.CurrentSectionName, + CurrentVideoTitle = state.CurrentVideoTitle, + CurrentVideoPos = state.CurrentVideoPos, + CurrentVideoLength = state.CurrentVideoLength, + RetryCount = state.RetryCount, + LastError = state.LastError, + CreatedAt = state.CreatedAt, + FinishedAt = state.FinishedAt, + }; + } + + private UserBrushState? RecordToState(BrushTaskRecord r) + { + try + { + var chapters = string.IsNullOrWhiteSpace(r.ChaptersJson) + ? new List() + : JsonSerializer.Deserialize>(r.ChaptersJson) ?? []; + + var sessionData = string.IsNullOrWhiteSpace(r.EncryptedSessionData) + ? new PlatformSessionData() + : GetSecretProtection().UnprotectSessionData(r.EncryptedSessionData); + + Enum.TryParse(r.Status, out var status); + + return new UserBrushState + { + RecordId = r.Id, + UserId = r.UserId, + PlatformSlug = r.PlatformSlug, + CourseId = r.CourseId, + Chapters = chapters, + SessionData = sessionData, + Status = status, + TotalVideos = r.TotalVideos, + CompletedVideos = r.CompletedVideos, + CurrentChapterName = r.CurrentChapterName, + CurrentSectionName = r.CurrentSectionName, + CurrentVideoTitle = r.CurrentVideoTitle, + CurrentVideoPos = r.CurrentVideoPos, + CurrentVideoLength = r.CurrentVideoLength, + RetryCount = r.RetryCount, + LastError = r.LastError, + CreatedAt = r.CreatedAt, + FinishedAt = r.FinishedAt, + }; + } + catch (Exception ex) + { + Console.WriteLine($"[Brush] Failed to deserialize task #{r.Id}: {ex.Message}"); + return null; + } + } + + // ── Queue Management ───────────────────────────────────── + + /// Try to start the next eligible queued task for a user. + private void TryStartNextTask(long userId) + { + UserBrushState? toStart = null; + lock (_queueLock) + { + if (!_userTasks.TryGetValue(userId, out var list)) return; + + // Find running tasks to check platform conflicts + var runningPlatforms = list + .Where(t => t.Status == BrushRunStatus.Running) + .Select(t => t.PlatformSlug) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Find first queued task that can run (no same-platform conflict) + var next = list.FirstOrDefault(t => + t.Status == BrushRunStatus.Queued && !runningPlatforms.Contains(t.PlatformSlug)); + + if (next is null) return; + + next.Status = BrushRunStatus.Running; + next.Cancelled = false; + next.RetryCount = 0; + next.LastError = null; + toStart = next; + } + + // Start the specific task we just transitioned (outside lock) + SaveToDb(toStart); + _ = Task.Run(() => RunAsync(toStart)); + } + + /// Called when a task finishes (completed/failed/stopped). + private void OnTaskFinished(UserBrushState state) + { + state.FinishedAt = DateTimeOffset.UtcNow; + // Persist all final states so they survive restarts + SaveToDb(state); + + // Try to start next queued task for same user + TryStartNextTask(state.UserId); + } + + // ── Public API ─────────────────────────────────────────── + + public List GetStatus(long userId) + { + if (!_userTasks.TryGetValue(userId, out var list)) return []; + + var running = list.Where(t => t.Status == BrushRunStatus.Running); + var queued = list.Where(t => t.Status == BrushRunStatus.Queued); + var finished = list.Where(t => t.Status == BrushRunStatus.Completed || t.Status == BrushRunStatus.Failed); + + var queuedCount = queued.Count(); + // Order: Running first, then Queued, then finished (most recent first) + return running.Concat(queued).Concat(finished) + .Select(t => t.ToDto() with { QueuedCount = queuedCount }) + .ToList(); + } + + public BrushStatusDto Start(long userId, long connectionId, string courseId, string platformSlug, + List chapters, + PlatformSessionData sessionData) + { + if (Config.PauseNewTasks) + throw new InvalidOperationException("系统已暂停接收新任务。"); + + var taskId = $"{userId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + + var state = new UserBrushState + { + TaskId = taskId, + UserId = userId, + ConnectionId = connectionId, + CourseId = courseId, + PlatformSlug = platformSlug, + Chapters = chapters, + Status = BrushRunStatus.Queued, + SessionData = sessionData, + TotalVideos = chapters.Sum(c => c.Sections.Sum(s => s.Videos.Count)), + }; + + lock (_queueLock) + { + var list = _userTasks.GetOrAdd(userId, _ => []); + list.Add(state); + } + + SaveToDb(state); + + // Try to start immediately (will queue if same platform already running) + TryStartNextTask(userId); + + return state.ToDto(); + } + + public void Stop(long userId) + { + lock (_queueLock) + { + if (!_userTasks.TryGetValue(userId, out var list)) return; + + // Cancel running task + var running = list.FirstOrDefault(t => t.Status == BrushRunStatus.Running); + if (running is not null) + running.Cancelled = true; + + // Clear all queued tasks for this user + foreach (var t in list.Where(t => t.Status == BrushRunStatus.Queued).ToList()) + { + t.Status = BrushRunStatus.Stopped; + t.FinishedAt = DateTimeOffset.UtcNow; + DeleteFromDb(t); + } + } + } + + public void StopTask(long userId, string taskId) + { + lock (_queueLock) + { + if (!_userTasks.TryGetValue(userId, out var list)) return; + var task = list.FirstOrDefault(t => t.TaskId == taskId); + if (task is null) return; + if (task.Status == BrushRunStatus.Running) + task.Cancelled = true; + if (task.Status is BrushRunStatus.Running or BrushRunStatus.Queued) + { + task.Status = BrushRunStatus.Stopped; + task.FinishedAt = DateTimeOffset.UtcNow; + OnTaskFinished(task); + } + } + } + + public BrushStatusDto RetryTask(long userId, string taskId) + { + UserBrushState? task; + lock (_queueLock) + { + if (!_userTasks.TryGetValue(userId, out var list)) + throw new InvalidOperationException("没有可重试的任务。"); + task = list.FirstOrDefault(t => t.TaskId == taskId && t.Status == BrushRunStatus.Failed); + } + + if (task is null) + throw new InvalidOperationException("只有失败的任务才能重试。"); + + task.Status = BrushRunStatus.Running; + task.RetryCount = 0; + task.LastError = null; + task.FinishedAt = null; + SaveToDb(task); + _ = Task.Run(() => RunAsync(task)); + return task.ToDto(); + } + + public void DeleteTask(long userId, string taskId) + { + lock (_queueLock) + { + if (!_userTasks.TryGetValue(userId, out var list)) return; + var task = list.FirstOrDefault(t => + t.TaskId == taskId && + t.Status is BrushRunStatus.Completed or BrushRunStatus.Failed or BrushRunStatus.Stopped); + if (task is null) return; + list.Remove(task); + DeleteFromDb(task); + } + } + + public BrushSystemConfig Config { get; } = new(); + + public IReadOnlyList GetAllTasks() + { + return _userTasks.SelectMany(kv => + { + var runningOrQueued = kv.Value + .Where(t => t.Status is BrushRunStatus.Running or BrushRunStatus.Queued) + .ToList(); + return runningOrQueued.Select(t => + { + var s = t.ToDto(); + return new AdminBrushTaskDto(kv.Key, s.Status, s.TotalVideos, s.CompletedVideos, + s.CurrentChapterName, s.CurrentSectionName, s.CurrentVideoTitle, + s.CurrentVideoPos, s.CurrentVideoLength, s.LastError, s.RetryCount); + }); + }).ToList(); + } + + public void AdminStop(long userId) + { + Stop(userId); + } + + public void StopAll() + { + lock (_queueLock) + { + foreach (var kv in _userTasks) + { + foreach (var t in kv.Value) + { + if (t.Status == BrushRunStatus.Running) + t.Cancelled = true; + if (t.Status == BrushRunStatus.Queued) + t.Status = BrushRunStatus.Stopped; + } + } + } + } + + public BrushStatusDto Retry(long userId) + { + UserBrushState? task; + lock (_queueLock) + { + if (!_userTasks.TryGetValue(userId, out var list)) + throw new InvalidOperationException("没有可重试的任务。"); + + task = list.FirstOrDefault(t => t.Status == BrushRunStatus.Failed); + } + + if (task is null) + throw new InvalidOperationException("只有失败的任务才能重试。"); + + task.Status = BrushRunStatus.Running; + task.RetryCount = 0; + task.LastError = null; + SaveToDb(task); + _ = Task.Run(() => RunAsync(task)); + return task.ToDto(); + } + + private async Task RunAsync(UserBrushState state) + { + if (state.PlatformSlug == "zhihuishu") + { + await RunZhihuishuAsync(state); + return; + } + + // ── UOOC (default) ── + var client = CreateUoocClient(state.SessionData); + var baseUrl = uoocOptions.Value.BaseUrl.TrimEnd('/'); + + try + { + for (var ci = 0; ci < state.Chapters.Count && !state.Cancelled; ci++) + { + var ch = state.Chapters[ci]; + state.CurrentChapterIndex = ci; + state.CurrentChapterName = ch.ChapterName; + + for (var si = 0; si < ch.Sections.Count && !state.Cancelled; si++) + { + var sec = ch.Sections[si]; + state.CurrentSectionIndex = si; + state.CurrentSectionName = sec.SectionName; + + // Lazy-load video resources for this section + if (sec.Videos.Count == 0) + { + try + { + var units = await uoocApiService.GetUnitsAsync(state.SessionData, + state.CourseId, ch.ChapterId, sec.SectionId, CancellationToken.None); + sec.Videos = units.Items + .Where(r => r.HasVideo && !r.Finished) // skip already-finished videos + .Select(r => new VideoBrushInput + { + ResourceId = r.Id, + Title = r.Title, + SectionCatalogId = r.CatalogId, + CdnUrl = r.PrimarySourceUrl ?? r.VideoSources.FirstOrDefault()?.Source ?? "", + VideoLength = 0, + }).ToList(); + + var skippedCount = units.Items.Count(r => r.HasVideo && r.Finished); + if (skippedCount > 0) + Console.WriteLine($"[UOOC Brush] Skipping {skippedCount} already-finished video(s) in {sec.SectionName}"); + + state.TotalVideos = state.Chapters.Sum(c => c.Sections.Sum(s => s.Videos.Count)); + } + catch (Exception ex) + { + state.LastError = $"加载小节资源失败: {ex.Message}"; + continue; + } + } + + for (var vi = 0; vi < sec.Videos.Count && !state.Cancelled; vi++) + { + var vid = sec.Videos[vi]; + state.CurrentVideoIndex = vi; + state.CurrentVideoTitle = vid.Title; + state.CurrentVideoPos = 0; + state.CurrentVideoLength = vid.VideoLength; + state.RetryCount = 0; + + // Determine video length if not provided + if (vid.VideoLength <= 0 && !string.IsNullOrWhiteSpace(vid.CdnUrl)) + vid.VideoLength = await ProbeVideoLengthAsync(vid.CdnUrl); + + if (vid.VideoLength <= 0) vid.VideoLength = 600; + state.CurrentVideoLength = vid.VideoLength; + + var pos = 0.0; + while (pos < vid.VideoLength && !state.Cancelled && state.RetryCount < 3) + { + pos += 20; + if (pos > vid.VideoLength) pos = vid.VideoLength; + state.CurrentVideoPos = pos; + state.LastError = null; + + try + { + var formData = new Dictionary + { + ["chapter_id"] = ch.ChapterId, + ["cid"] = state.CourseId, + ["hidemsg_"] = "true", + ["network"] = "2", + ["resource_id"] = vid.ResourceId, + ["section_id"] = vid.SectionCatalogId, + ["source"] = "1", + ["subsection_id"] = "0", + ["video_length"] = vid.VideoLength.ToString("F2"), + ["video_pos"] = pos.ToString("F2"), + }; + + using var content = new FormUrlEncodedContent(formData); + var requestBody = string.Join("&", formData.Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value)}")); + Console.WriteLine($"[markVideoLearn] REQUEST: {requestBody}"); + + var resp = await client.PostAsync($"{baseUrl}/home/learn/markVideoLearn", content); + var json = await resp.Content.ReadAsStringAsync(); + Console.WriteLine($"[markVideoLearn] RESPONSE {resp.StatusCode}: {json}"); + + if (!resp.IsSuccessStatusCode) + throw new Exception($"HTTP {(int)resp.StatusCode}"); + + var code = ParseCode(json); + if (code != 1) + throw new Exception($"code={code}"); + } + catch (Exception ex) + { + state.RetryCount++; + state.LastError = ex.Message; + if (state.RetryCount >= 3) + { + state.Status = BrushRunStatus.Failed; + state.LastError = $"重试 {state.RetryCount} 次后仍失败: {ex.Message}"; + OnTaskFinished(state); + return; + } + await Task.Delay(5000); // wait before retry + continue; + } + + state.RetryCount = 0; + if (pos >= vid.VideoLength) break; + await Task.Delay(10000); + } + + if (state.RetryCount >= 3 || state.Status == BrushRunStatus.Failed) + return; + + // Verify finished after completion + if (!state.Cancelled) + { + state.CurrentVideoPos = vid.VideoLength; + for (var r = 0; r < 3; r++) + { + try + { + var verified = await VerifyUoocFinishedAsync(state.SessionData, state.CourseId, + ch.ChapterId, vid.SectionCatalogId, vid.ResourceId); + vid.Finished = verified; + break; + } + catch { if (r == 2) throw; await Task.Delay(2000); } + } + state.CompletedVideos++; + SaveToDb(state); + } + } + } + } + + state.Status = state.Cancelled ? BrushRunStatus.Stopped : BrushRunStatus.Completed; + OnTaskFinished(state); + } + catch (Exception ex) + { + state.Status = BrushRunStatus.Failed; + state.LastError = ex.Message; + OnTaskFinished(state); + } + } + + // ── Zhihuishu Brushing ────────────────────────────────── + + private async Task RunZhihuishuAsync(UserBrushState state) + { + try + { + Console.WriteLine($"[Zhihuishu Brush] ===== START: CourseId={state.CourseId}, Chapters={state.Chapters.Count} ====="); + for (var i = 0; i < state.Chapters.Count; i++) + { + var ch = state.Chapters[i]; + Console.WriteLine($"[Zhihuishu Brush] Chapter[{i}]: id={ch.ChapterId}, name={ch.ChapterName}, sections={ch.Sections.Count}"); + for (var j = 0; j < ch.Sections.Count; j++) + Console.WriteLine($"[Zhihuishu Brush] Section[{j}]: id={ch.Sections[j].SectionId}, name={ch.Sections[j].SectionName}"); + } + + // Get course metadata (recruitId, ccCourseId) + var (recruitId, ccCourseId) = ZhihuishuApiService.GetCourseMeta(state.SessionData, state.CourseId); + Console.WriteLine($"[Zhihuishu Brush] GetCourseMeta: recruitId={recruitId}, ccCourseId={ccCourseId}"); + if (string.IsNullOrWhiteSpace(recruitId)) + { + state.Status = BrushRunStatus.Failed; + state.LastError = "无法获取课程 recruitId,请重新查询课程列表后重试。"; + Console.WriteLine($"[Zhihuishu Brush] FAILED: {state.LastError}"); + OnTaskFinished(state); + return; + } + + // Get UUID from session data + var uuid = ""; + foreach (var kv in state.SessionData.Cookies) + { + if (kv.Key.Equals("CASLOGC", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(kv.Value)) + { + try + { + var decoded = System.Web.HttpUtility.UrlDecode(kv.Value); + using var doc = System.Text.Json.JsonDocument.Parse(decoded); + uuid = doc.RootElement.TryGetProperty("uuid", out var u) ? u.GetString() ?? "" : ""; + } + catch { } + break; + } + } + + // Fetch full catalog to get video metadata for each lesson + Console.WriteLine($"[Zhihuishu Brush] Fetching catalog..."); + var catalog = await zhihuishuApiService.GetCatalogAsync(state.SessionData, state.CourseId, CancellationToken.None); + Console.WriteLine($"[Zhihuishu Brush] Catalog returned: {catalog.Chapters.Count} chapters"); + + var totalVideos = 0; + var videoTasks = new List<(string ChapterId, string ChapterName, string LessonId, string LessonName, string VideoId, string SmallLessonId, double VideoSec)>(); + + foreach (var ch in state.Chapters) + { + var catChapter = catalog.Chapters.FirstOrDefault(c => c.Id == ch.ChapterId); + if (catChapter is null) + { + Console.WriteLine($"[Zhihuishu Brush] WARNING: chapter id={ch.ChapterId} NOT FOUND in catalog! Catalog chapter ids: [{string.Join(", ", catalog.Chapters.Select(c => c.Id))}]"); + continue; + } + + Console.WriteLine($"[Zhihuishu Brush] Chapter matched: {catChapter.Name}, sections={catChapter.Sections.Count}"); + + foreach (var sec in ch.Sections) + { + var catSection = catChapter.Sections.FirstOrDefault(s => s.Id == sec.SectionId); + if (catSection is null) + { + Console.WriteLine($"[Zhihuishu Brush] WARNING: section id={sec.SectionId} NOT FOUND in catalog chapter! Catalog section ids: [{string.Join(", ", catChapter.Sections.Select(s => s.Id))}]"); + continue; + } + + Console.WriteLine($"[Zhihuishu Brush] Section matched: {catSection.Name}, TaskId={catSection.TaskId}"); + + // Parse video metadata from TaskId JSON + try + { + using var videoDoc = System.Text.Json.JsonDocument.Parse(catSection.TaskId); + var vCount = 0; + foreach (var vi in videoDoc.RootElement.EnumerateArray()) + { + var slId = ""; + var vId = ""; + double vSec = 0; + if (vi.TryGetProperty("slId", out var s)) slId = s.GetString() ?? ""; + if (vi.TryGetProperty("vId", out var v)) vId = v.GetString() ?? ""; + if (vi.TryGetProperty("vSec", out var d)) vSec = d.GetDouble(); + + if (!string.IsNullOrWhiteSpace(vId)) + { + videoTasks.Add((ch.ChapterId, ch.ChapterName, sec.SectionId, + catSection.Name, vId, slId, vSec)); + totalVideos++; + vCount++; + } + } + Console.WriteLine($"[Zhihuishu Brush] Parsed {vCount} videos from TaskId"); + } + catch (Exception ex) + { + // Section has no video metadata — skip it + Console.WriteLine($"[Zhihuishu Brush] Section {catSection.Name}: no parseable video data, skipping. ({ex.Message})"); + } + } + } + + Console.WriteLine($"[Zhihuishu Brush] Total video tasks: {totalVideos}"); + + if (totalVideos == 0) + { + state.Status = BrushRunStatus.Completed; + state.LastError = null; + Console.WriteLine($"[Zhihuishu Brush] No videos to brush — marking as completed."); + OnTaskFinished(state); + return; + } + + state.TotalVideos = totalVideos; + + foreach (var (chapterId, chapterName, lessonId, lessonName, videoId, smallLessonId, videoSec) in videoTasks) + { + if (state.Cancelled) break; + + state.CurrentChapterName = chapterName; + state.CurrentSectionName = lessonName; + state.CurrentVideoTitle = lessonName; + + var videoLength = videoSec > 0 ? videoSec : 600; + state.CurrentVideoLength = videoLength; + state.CurrentVideoPos = 0; + state.RetryCount = 0; + + Console.WriteLine($"[Zhihuishu Brush] {chapterName}/{lessonName}: videoId={videoId}, slId={smallLessonId}, sec={videoSec}"); + + // Step 1: prelearningNote + ZhihuishuApiService.PrelearningResult preResult; + try + { + preResult = await zhihuishuApiService.PrelearningNoteAsync( + state.SessionData, ccCourseId, chapterId, lessonId, + smallLessonId, recruitId, videoId, CancellationToken.None); + } + catch (Exception ex) + { + state.LastError = $"prelearningNote 失败: {ex.Message}"; + Console.WriteLine($"[Zhihuishu Brush] {state.LastError}"); + state.Status = BrushRunStatus.Failed; + OnTaskFinished(state); + return; + } + + if (string.IsNullOrWhiteSpace(preResult.LearningTokenId)) + { + state.LastError = "prelearningNote 未返回 token"; + state.Status = BrushRunStatus.Failed; + OnTaskFinished(state); + return; + } + + Console.WriteLine($"[Zhihuishu Brush] prelearningNote OK, token={preResult.LearningTokenId[..Math.Min(20, preResult.LearningTokenId.Length)]}..., previousTime={preResult.PreviousStudyTime}s"); + + // Step 2: Loop saveDatabaseIntervalTimeV2 + // Start from the previously studied time so the server doesn't reject "decreased" time + var playedTime = preResult.PreviousStudyTime; + var increment = 30.0; + + // Skip brushing if already watched beyond video length + if (playedTime >= videoLength) + { + Console.WriteLine($"[Zhihuishu Brush] Already completed (prevTime={playedTime} >= vLen={videoLength}), skipping."); + state.CompletedVideos++; + continue; + } + + while (playedTime < videoLength && !state.Cancelled && state.RetryCount < 3) + { + playedTime += increment; + if (playedTime > videoLength) playedTime = videoLength; + state.CurrentVideoPos = playedTime; + state.LastError = null; + + try + { + var ok = await zhihuishuApiService.SaveDatabaseIntervalTimeV2Async( + state.SessionData, recruitId, lessonId, smallLessonId, + videoId, chapterId, uuid, playedTime, increment, + preResult.LearningTokenId, ccCourseId, CancellationToken.None); + + Console.WriteLine($"[Zhihuishu Brush] saveProgress: pos={playedTime}/{videoLength}, ok={ok}"); + + if (!ok) + throw new Exception("saveDatabaseIntervalTimeV2 返回失败"); + } + catch (Exception ex) + { + state.RetryCount++; + state.LastError = ex.Message; + playedTime -= increment; // undo the increment so retry uses same position + Console.WriteLine($"[Zhihuishu Brush] Error (retry {state.RetryCount}/3): {ex.Message}"); + if (state.RetryCount >= 3) + { + state.Status = BrushRunStatus.Failed; + state.LastError = $"重试 {state.RetryCount} 次后仍失败: {ex.Message}"; + return; + } + await Task.Delay(5000); + continue; + } + + state.RetryCount = 0; + if (playedTime >= videoLength) break; + await Task.Delay(30000); + } + + if (state.RetryCount >= 3 || state.Status == BrushRunStatus.Failed) + return; + + state.CompletedVideos++; + } + + state.Status = state.Cancelled ? BrushRunStatus.Stopped : BrushRunStatus.Completed; + OnTaskFinished(state); + } + catch (Exception ex) + { + state.Status = BrushRunStatus.Failed; + state.LastError = ex.Message; + OnTaskFinished(state); + Console.WriteLine($"[Zhihuishu Brush] Fatal error: {ex}"); + } + } + + private async Task ProbeVideoLengthAsync(string cdnUrl) + { + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var request = new HttpRequestMessage(HttpMethod.Get, cdnUrl); + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 500000); + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[500000]; + var read = await stream.ReadAsync(buffer); + + // Parse MP4 mvhd atom for duration + return ParseMp4Duration(buffer.AsSpan(0, read)); + } + catch + { + return 0; + } + } + + private static double ParseMp4Duration(ReadOnlySpan data) + { + // Find mvhd box inside moov + var moovIdx = IndexOf(data, "moov"u8); + if (moovIdx < 0) return 0; + var mvhdIdx = IndexOf(data.Slice(moovIdx), "mvhd"u8); + if (mvhdIdx < 0) return 0; + + var mvhd = data.Slice(moovIdx + mvhdIdx); + if (mvhd.Length < 24) return 0; + var version = mvhd[8]; + int timescaleOffset, durationOffset; + if (version == 1) + { + timescaleOffset = 20; + durationOffset = 24; + if (mvhd.Length < 32) return 0; + var duration = (long)ReadUInt64BigEndian(mvhd.Slice(durationOffset)); + var timescale = ReadUInt32BigEndian(mvhd.Slice(timescaleOffset)); + return timescale > 0 ? (double)duration / timescale : 0; + } + else + { + timescaleOffset = 12; + durationOffset = 16; + var duration = ReadUInt32BigEndian(mvhd.Slice(durationOffset)); + var timescale = ReadUInt32BigEndian(mvhd.Slice(timescaleOffset)); + return timescale > 0 ? (double)duration / timescale : 0; + } + } + + private static int IndexOf(ReadOnlySpan span, ReadOnlySpan pattern) + { + for (var i = 0; i <= span.Length - pattern.Length; i++) + if (span.Slice(i, pattern.Length).SequenceEqual(pattern)) + return i; + return -1; + } + + private static uint ReadUInt32BigEndian(ReadOnlySpan span) => + ((uint)span[0] << 24) | ((uint)span[1] << 16) | ((uint)span[2] << 8) | span[3]; + + private static ulong ReadUInt64BigEndian(ReadOnlySpan span) => + ((ulong)ReadUInt32BigEndian(span) << 32) | ReadUInt32BigEndian(span.Slice(4)); + + private static int ParseCode(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("code", out var el) && el.TryGetInt32(out var v) ? v : -1; + } + catch { return -1; } + } + + private async Task VerifyUoocFinishedAsync(PlatformSessionData sessionData, string courseId, + string chapterId, string sectionId, string resourceId) + { + try + { + var units = await uoocApiService.GetUnitsAsync(sessionData, courseId, chapterId, sectionId, CancellationToken.None); + var resource = units.Items.FirstOrDefault(r => r.Id == resourceId); + return resource?.Finished == true; + } + catch + { + return false; + } + } + + private HttpClient CreateUoocClient(PlatformSessionData sessionData) + { + var client = httpClientFactory.CreateClient("platform-workflow"); + if (sessionData.Cookies.TryGetValue("uooc_auth", out var cookie)) + client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", $"uooc_auth={cookie}"); + return client; + } +} + +// --- Models --- + +public sealed class UserBrushState +{ + public long RecordId { get; set; } // DB primary key (0 = not yet persisted) + public string TaskId { get; set; } = ""; // unique task identifier + public long UserId { get; set; } + public long ConnectionId { get; set; } + public string CourseId { get; set; } = ""; + public string PlatformSlug { get; set; } = ""; // "uooc" or "zhihuishu" + public List Chapters { get; set; } = []; + public PlatformSessionData SessionData { get; set; } = new(); + public BrushRunStatus Status { get; set; } = BrushRunStatus.Queued; + public int TotalVideos { get; set; } + public int CompletedVideos { get; set; } + public int CurrentChapterIndex { get; set; } + public string CurrentChapterName { get; set; } = ""; + public int CurrentSectionIndex { get; set; } + public string CurrentSectionName { get; set; } = ""; + public int CurrentVideoIndex { get; set; } + public string CurrentVideoTitle { get; set; } = ""; + public double CurrentVideoPos { get; set; } + public double CurrentVideoLength { get; set; } + public int RetryCount { get; set; } + public string? LastError { get; set; } + public bool Cancelled { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? FinishedAt { get; set; } + + public BrushStatusDto ToDto() => new( + TaskId, + PlatformSlug, + CourseId, + string.Join("、", Chapters.Select(c => c.ChapterName)), + Status.ToString(), + TotalVideos, + CompletedVideos, + CurrentChapterName, + CurrentSectionName, + CurrentVideoTitle, + CurrentVideoPos, + CurrentVideoLength, + LastError, + RetryCount, + CreatedAt, + Status is BrushRunStatus.Completed or BrushRunStatus.Failed or BrushRunStatus.Stopped + ? (FinishedAt - CreatedAt)?.TotalSeconds ?? 0 + : (DateTimeOffset.UtcNow - CreatedAt).TotalSeconds, + 0); // QueuedCount filled in by GetStatus +} + +public sealed class ChapterBrushInput +{ + public string ChapterId { get; set; } = ""; + public string ChapterName { get; set; } = ""; + public List Sections { get; set; } = []; +} + +public sealed class SectionBrushInput +{ + public string SectionId { get; set; } = ""; + public string SectionName { get; set; } = ""; + public List Videos { get; set; } = []; +} + +public sealed class VideoBrushInput +{ + public string ResourceId { get; set; } = ""; + public string Title { get; set; } = ""; + public string SectionCatalogId { get; set; } = ""; + public string CdnUrl { get; set; } = ""; + public double VideoLength { get; set; } + public bool Finished { get; set; } +} + +public enum BrushRunStatus +{ + Queued, + Running, + Completed, + Failed, + Stopped +} + +public sealed record AdminBrushTaskDto( + long UserId, + string Status, + int TotalVideos, + int CompletedVideos, + string CurrentChapterName, + string CurrentSectionName, + string CurrentVideoTitle, + double CurrentVideoPos, + double CurrentVideoLength, + string? LastError, + int RetryCount); + +public sealed record BrushStatusDto( + string TaskId, + string PlatformSlug, + string CourseId, + string ChaptersSummary, + string Status, + int TotalVideos, + int CompletedVideos, + string CurrentChapterName, + string CurrentSectionName, + string CurrentVideoTitle, + double CurrentVideoPos, + double CurrentVideoLength, + string? LastError, + int RetryCount, + DateTimeOffset CreatedAt, + double DurationSeconds, + int QueuedCount = 0); diff --git a/backend/src/UoocProgress.Api/Services/ZhihuishuApiService.cs b/backend/src/UoocProgress.Api/Services/ZhihuishuApiService.cs new file mode 100644 index 0000000..341f081 --- /dev/null +++ b/backend/src/UoocProgress.Api/Services/ZhihuishuApiService.cs @@ -0,0 +1,965 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Web; +using Microsoft.Extensions.Options; +using UoocProgress.Api.Models; +using UoocProgress.Api.Options; + +namespace UoocProgress.Api.Services; + +/// +/// Direct Zhihuishu (智慧树) API calls. +/// Handles CAS login, AES-encrypted Zhidao APIs, and Hike APIs. +/// +public sealed class ZhihuishuApiService( + IHttpClientFactory httpClientFactory, + IOptions options) +{ + private ZhihuishuOptions Opt => options.Value; + + // ── AES Keys ── + private static readonly byte[] HomeKey = "7q9oko0vqb3la20r"u8.ToArray(); + private static readonly byte[] VideoKey = "azp53h0kft7qi78q"u8.ToArray(); + private static readonly byte[] QaKey = "kcGOlISPkYKRksSK"u8.ToArray(); + private static readonly byte[] AesIv = "1g3qqdh4jvbskb9x"u8.ToArray(); + + // ── Hike MD5 Salt ── + private const string HikeSalt = "o6xpt3b#Qy$Z"; + + // ── Login ────────────────────────────────────────────── + + public sealed class ZhihuishuLoginResult + { + public PlatformSessionData SessionData { get; init; } = new(); + public string Uuid { get; init; } = ""; + public string UserName { get; init; } = ""; + public string UserId { get; init; } = ""; + } + + /// + /// Full CAS login flow for Zhihuishu. + /// Steps: GET passport/login → POST validateAccountAndPassword → POST checkNeedAuth → GET CAS redirect chain. + /// + public async Task LoginAsync( + string account, + string password, + string captchaValidate, + CancellationToken ct) + { + // Use a cookie-aware handler for redirect following + var cookieContainer = new CookieContainer(); + using var handler = new HttpClientHandler + { + CookieContainer = cookieContainer, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10 + }; + + using var client = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(Opt.TimeoutSeconds) + }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + client.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + + // Step 1: GET passport login page → get JSESSIONID + Console.WriteLine("[Zhihuishu] Step 1: GET passport login page"); + await client.GetAsync($"{Opt.PassportBaseUrl}/login", ct); + + // Step 2: POST validateAccountAndPassword + Console.WriteLine("[Zhihuishu] Step 2: POST validateAccountAndPassword"); + var loginJson = JsonSerializer.Serialize(new + { + account, + password, + validate = captchaValidate + }); + var secretStr = Convert.ToBase64String(Encoding.UTF8.GetBytes(HttpUtility.UrlEncode(loginJson))); + + using var step2Content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr + }); + + using var step2Req = new HttpRequestMessage(HttpMethod.Post, + $"{Opt.PassportBaseUrl}/user/validateAccountAndPassword") + { + Content = step2Content + }; + step2Req.Headers.Referrer = new Uri($"{Opt.PassportBaseUrl}/login"); + step2Req.Headers.TryAddWithoutValidation("Origin", Opt.PassportBaseUrl); + + var step2Resp = await client.SendAsync(step2Req, ct); + var step2Text = await step2Resp.Content.ReadAsStringAsync(ct); + Console.WriteLine($"[Zhihuishu] Step 2 response: {Truncate(step2Text, 300)}"); + + using var step2Doc = JsonDocument.Parse(step2Text); + var root = step2Doc.RootElement; + + var status = root.TryGetProperty("status", out var st) ? st.GetInt32() : 0; + if (status != 1) + { + var msg = root.TryGetProperty("msg", out var m) ? m.GetString() : "账号或密码错误"; + throw new PlatformOperationException($"智慧树登录失败:{msg}"); + } + + var uuid = root.TryGetProperty("uuid", out var u) ? u.GetString() ?? "" : ""; + var pwd = root.TryGetProperty("pwd", out var p) ? p.GetString() ?? "" : ""; + + if (string.IsNullOrWhiteSpace(uuid) || string.IsNullOrWhiteSpace(pwd)) + throw new PlatformOperationException("智慧树登录失败:未获取到令牌。"); + + // Step 3: POST checkNeedAuth + Console.WriteLine("[Zhihuishu] Step 3: POST checkNeedAuth"); + using var step3Content = new FormUrlEncodedContent(new Dictionary + { + ["uuid"] = uuid + }); + using var step3Req = new HttpRequestMessage(HttpMethod.Post, + $"{Opt.AppcommUserBaseUrl}/appcomm-user/validate/checkNeedAuth") + { + Content = step3Content + }; + step3Req.Headers.TryAddWithoutValidation("Origin", Opt.PassportBaseUrl); + step3Req.Headers.Referrer = new Uri($"{Opt.PassportBaseUrl}/login"); + + await client.SendAsync(step3Req, ct); + + // Step 4: GET CAS redirect chain → CASLOGC cookie + API SESSION cookies + Console.WriteLine("[Zhihuishu] Step 4: GET CAS redirect (onlineservice-api)"); + var casUrl = $"{Opt.PassportBaseUrl}/login?pwd={HttpUtility.UrlEncode(pwd)}&service={HttpUtility.UrlEncode(Opt.CasServiceUrl)}"; + var casResp = await client.GetAsync(casUrl, ct); + var casBody = await casResp.Content.ReadAsStringAsync(ct); + Console.WriteLine($"[Zhihuishu] Step 4 final URL: {casResp.RequestMessage?.RequestUri}, body length: {casBody.Length}"); + + // Extract CASLOGC cookie + var caslogc = cookieContainer.GetCookies(new Uri(Opt.PassportBaseUrl)) + .FirstOrDefault(c => c.Name == "CASLOGC")?.Value ?? ""; + + Console.WriteLine($"[Zhihuishu] CASLOGC cookie: {(string.IsNullOrWhiteSpace(caslogc) ? "MISSING" : "OK")}"); + + // Build session data with all cookies + var sessionData = new PlatformSessionData(); + + // Collect all cookies from the container + foreach (Cookie cookie in cookieContainer.GetAllCookies()) + { + sessionData.Cookies[cookie.Name] = cookie.Value; + } + + // Also add cookies keyed by domain for cross-domain access + var passportCookies = cookieContainer.GetCookies(new Uri(Opt.PassportBaseUrl)); + foreach (Cookie cookie in passportCookies) + sessionData.Cookies[$"{GetDomainPrefix(Opt.PassportBaseUrl)}.{cookie.Name}"] = cookie.Value; + + var onlineCookies = cookieContainer.GetCookies(new Uri(Opt.OnlineServiceBaseUrl)); + foreach (Cookie cookie in onlineCookies) + sessionData.Cookies[$"{GetDomainPrefix(Opt.OnlineServiceBaseUrl)}.{cookie.Name}"] = cookie.Value; + + var studyCookies = cookieContainer.GetCookies(new Uri(Opt.StudyServiceBaseUrl)); + foreach (Cookie cookie in studyCookies) + sessionData.Cookies[$"{GetDomainPrefix(Opt.StudyServiceBaseUrl)}.{cookie.Name}"] = cookie.Value; + + // Parse CASLOGC for uuid/user info + var userName = ""; + var userId = ""; + if (!string.IsNullOrWhiteSpace(caslogc)) + { + try + { + var decoded = HttpUtility.UrlDecode(caslogc); + using var casDoc = JsonDocument.Parse(decoded); + userName = casDoc.RootElement.TryGetProperty("realName", out var rn) ? rn.GetString() ?? "" : ""; + userId = casDoc.RootElement.TryGetProperty("userId", out var uid) ? uid.GetString() ?? "" : ""; + } + catch { /* CASLOGC parse error - non-fatal */ } + } + + return new ZhihuishuLoginResult + { + SessionData = sessionData, + Uuid = uuid, + UserName = userName, + UserId = userId + }; + } + + // ── Course List (Zhidao) ─────────────────────────────── + + public async Task> GetCoursesAsync( + PlatformSessionData sessionData, + int pageNo = 1, + int pageSize = 10, + CancellationToken ct = default) + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var payload = JsonSerializer.Serialize(new + { + status = 0, + pageNo, + pageSize, + dateFormate = ts + }); + + var secretStr = AesEncrypt(payload, HomeKey); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr, + ["dateFormate"] = ts.ToString() + }); + + var client = CreateZhidaoClient(sessionData, Opt.OnlineServiceBaseUrl); + var url = $"{Opt.OnlineServiceBaseUrl}/gateway/t/v1/student/course/share/queryShareCourseInfo"; + Console.WriteLine($"[Zhihuishu] GetCourses POST {url}"); + + HttpResponseMessage resp; + try + { + resp = await client.PostAsync(url, content, ct); + } + catch (Exception ex) + { + Console.WriteLine($"[Zhihuishu] GetCourses HTTP error: {ex}"); + throw new PlatformOperationException($"智慧树课程列表请求失败:{ex.Message}"); + } + + var json = await ReadZhidaoJson(resp, ct); + var items = new List(); + + var courseList = json.RootElement.TryGetProperty("result", out var r) + && r.TryGetProperty("courseOpenDtos", out var dtos) + ? dtos.EnumerateArray().ToList() + : []; + + foreach (var course in courseList) + { + var secret = GetStr(course, "secret"); // RAC_id + var name = GetStr(course, "courseName"); + var recruitId = GetStr(course, "recruitId"); + var ccCourseId = GetStr(course, "courseId"); + + Console.WriteLine($"[Zhihuishu] Course: name={name}, secret={secret}, recruitId={recruitId}, courseId={ccCourseId}"); + + if (!string.IsNullOrWhiteSpace(secret) && !string.IsNullOrWhiteSpace(name)) + { + items.Add(new CourseOptionDto(secret, name)); + + // Store metadata for brushing: recruitId and ccCourseId keyed by RAC_id + if (!string.IsNullOrWhiteSpace(recruitId) || !string.IsNullOrWhiteSpace(ccCourseId)) + { + sessionData.Outputs[$"zhs_meta_{secret}"] = + JsonSerializer.Serialize(new { recruitId, courseId = ccCourseId }); + } + } + } + + return items; + } + + // ── Catalog / Video List (Zhidao) ────────────────────── + + public async Task GetCatalogAsync( + PlatformSessionData sessionData, + string racId, + CancellationToken ct = default) + { + // Try direct approach first — use existing onlineservice-api SESSION cookie for studyservice-api. + // In many Zhihuishu setups, the SESSION is shared across subdomains. + var client = CreateZhidaoClient(sessionData, Opt.StudyServiceBaseUrl); + + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + // HARDCODED TEST: try recruitId_courseId format from course list metadata + var payload = JsonSerializer.Serialize(new + { + recruitAndCourseId = racId, + dateFormate = ts + }); + Console.WriteLine($"[Zhihuishu] Catalog AES plaintext: {payload}"); + + var secretStr = AesEncrypt(payload, VideoKey); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr, + ["dateFormate"] = ts.ToString() + }); + + var url = $"{Opt.StudyServiceBaseUrl}/gateway/t/v1/learning/videolist"; + Console.WriteLine($"[Zhihuishu] Catalog POST {url}"); + + var resp = await client.PostAsync(url, content, ct); + var respBody = await resp.Content.ReadAsStringAsync(ct); + Console.WriteLine($"[Zhihuishu] Catalog response: {resp.StatusCode}, body: {Truncate(respBody, 500)}"); + + // Validate response + JsonDocument jsonDoc; + try { jsonDoc = JsonDocument.Parse(respBody); } + catch { throw new PlatformOperationException($"智慧树接口返回非JSON:{Truncate(respBody, 100)}"); } + + if (!resp.IsSuccessStatusCode) + throw new PlatformOperationException($"智慧树接口返回 {(int)resp.StatusCode}:{Truncate(respBody, 100)}"); + + var respCode = jsonDoc.RootElement.TryGetProperty("code", out var rc) ? rc.GetInt32() : -1; + if (respCode != 0 && respCode != 200) + { + var msg = jsonDoc.RootElement.TryGetProperty("message", out var m) ? m.GetString() ?? "" : ""; + throw new PlatformOperationException($"智慧树接口错误 (code={respCode}): {msg}"); + } + + var json = jsonDoc; + + var data = json.RootElement.TryGetProperty("data", out var d) ? d : default; + var courseId = GetStr(data, "courseId"); + + var chapters = new List(); + if (data.TryGetProperty("videoChapterDtos", out var chArr)) + { + foreach (var ch in chArr.EnumerateArray()) + { + var chapterId = GetStr(ch, "id"); + var chapterName = GetStr(ch, "name"); + + var sections = new List(); + if (ch.TryGetProperty("videoLessons", out var lessons)) + { + foreach (var lesson in lessons.EnumerateArray()) + { + var lessonId = GetStr(lesson, "id"); + var lessonName = GetStr(lesson, "name"); + + // Collect small lesson details for progress tracking + var videoInfos = new List(); + double totalDuration = 0; + if (lesson.TryGetProperty("videoSmallLessons", out var smallLessons)) + { + var slRaw = smallLessons.GetRawText(); + Console.WriteLine($"[Zhihuishu] lesson {lessonName}: videoSmallLessons={Truncate(slRaw, 500)}"); + foreach (var sl in smallLessons.EnumerateArray()) + { + var slId = GetStr(sl, "id"); + var vId = GetStr(sl, "videoId"); + var vSec = sl.TryGetProperty("videoSec", out var vs) ? vs.GetDouble() : 0; + videoInfos.Add(new { slId = slId, vId = vId, vSec = vSec }); + totalDuration += vSec; + } + } + else + { + // Single video lesson: videoId and videoSec are directly on the lesson object + var vId = GetStr(lesson, "videoId"); + var vSec = lesson.TryGetProperty("videoSec", out var vs) ? vs.GetDouble() : 0; + Console.WriteLine($"[Zhihuishu] lesson {lessonName}: single video, videoId={vId}, videoSec={vSec}"); + if (!string.IsNullOrWhiteSpace(vId)) + { + videoInfos.Add(new { slId = "0", vId = vId, vSec = vSec }); + totalDuration += vSec; + } + } + + // Encode video info as JSON in TaskId field + var taskIdJson = JsonSerializer.Serialize(videoInfos); + + sections.Add(new CatalogSectionDto( + lessonId, + "", // number + lessonName, + false, // finished + false, // learning + taskIdJson // taskId stores video metadata JSON + )); + } + } + + chapters.Add(new CatalogChapterDto( + chapterId, + "", // number + chapterName, + false, + false, + sections)); + } + } + + return new CatalogResponse(racId, chapters, false, "upstream", null); + } + + // ── Video Play URL ───────────────────────────────────── + + public async Task GetVideoUrlAsync( + PlatformSessionData sessionData, + string videoId, + CancellationToken ct = default) + { + var client = CreateZhidaoClient(sessionData, Opt.NewbaseUrl); + var resp = await client.GetAsync( + $"{Opt.NewbaseUrl}/video/initVideo?jsonpCallBack=result&videoID={Uri.EscapeDataString(videoId)}", ct); + + var text = await resp.Content.ReadAsStringAsync(ct); + // Parse JSONP: result({...}) + var jsonpStart = text.IndexOf('('); + var jsonpEnd = text.LastIndexOf(')'); + if (jsonpStart < 0 || jsonpEnd < 0) return null; + var json = text[(jsonpStart + 1)..jsonpEnd]; + + using var doc = JsonDocument.Parse(json); + var lines = doc.RootElement.TryGetProperty("lines", out var l) ? l : default; + if (lines.ValueKind == JsonValueKind.Array && lines.GetArrayLength() > 0) + { + return GetStr(lines[0], "lineUrl"); + } + + return null; + } + + // ── Study Info ───────────────────────────────────────── + + public sealed class LessonProgress + { + public int WatchState { get; set; } // 0=未看完, 1=已看完 + public double StudyTotalTime { get; set; } // 已学习秒数 + } + + /// Query study progress for a batch of lessons. Returns dict keyed by lessonId or smallLessonId. + public async Task> QueryLessonProgressAsync( + PlatformSessionData sessionData, + List lessonIds, + List lessonVideoIds, + string recruitId, + CancellationToken ct = default) + { + var result = new Dictionary(); + if (lessonIds.Count == 0) return result; + + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var payload = JsonSerializer.Serialize(new + { + lessonIds, + lessonVideoIds = lessonVideoIds.Count == 0 ? new List() : lessonVideoIds, + recruitId, + dateFormate = ts + }); + + var secretStr = AesEncrypt(payload, VideoKey); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr, + ["dateFormate"] = ts.ToString() + }); + + var client = CreateZhidaoClient(sessionData, Opt.StudyServiceBaseUrl); + var resp = await client.PostAsync( + $"{Opt.StudyServiceBaseUrl}/gateway/t/v1/learning/queryStuyInfo", + content, ct); + + var json = await ReadZhidaoJson(resp, ct); + var data = json.RootElement.TryGetProperty("data", out var d) ? d : default; + + // Parse lesson-level progress + if (data.TryGetProperty("lesson", out var lessonObj)) + { + foreach (var prop in lessonObj.EnumerateObject()) + { + var state = prop.Value.TryGetProperty("watchState", out var ws) ? ws.GetInt32() : 0; + var time = prop.Value.TryGetProperty("studyTotalTime", out var st) ? st.GetDouble() : 0; + result[prop.Name] = new LessonProgress { WatchState = state, StudyTotalTime = time }; + } + } + + // Parse lv-level progress (small lesson / video level) + if (data.TryGetProperty("lv", out var lvObj)) + { + foreach (var prop in lvObj.EnumerateObject()) + { + var state = prop.Value.TryGetProperty("watchState", out var ws) ? ws.GetInt32() : 0; + var time = prop.Value.TryGetProperty("studyTotalTime", out var st) ? st.GetDouble() : 0; + result[prop.Name] = new LessonProgress { WatchState = state, StudyTotalTime = time }; + } + } + + return result; + } + + // ── Video Pointer Info (弹题) ────────────────────────── + + public async Task LoadVideoPointerInfoAsync( + PlatformSessionData sessionData, + string lessonId, + string lessonVideoId, + string recruitId, + string courseId, + CancellationToken ct = default) + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var payload = JsonSerializer.Serialize(new + { + lessonId, + lessonVideoId, + recruitId, + courseId, + dateFormate = ts + }); + + var secretStr = AesEncrypt(payload, VideoKey); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr, + ["dateFormate"] = ts.ToString() + }); + + var client = CreateZhidaoClient(sessionData, Opt.StudyServiceBaseUrl); + var resp = await client.PostAsync( + $"{Opt.StudyServiceBaseUrl}/gateway/t/v1/popupAnswer/loadVideoPointerInfo", + content, ct); + + return await ReadZhidaoJson(resp, ct); + } + + // ── Prelearning Note (step 1 before progress submit) ─── + + public sealed class PrelearningResult + { + public string LearningTokenId { get; init; } = ""; + public string StudiedLessonId { get; init; } = ""; + public double PreviousStudyTime { get; init; } // seconds already studied + } + + public async Task PrelearningNoteAsync( + PlatformSessionData sessionData, + string courseId, + string chapterId, + string lessonId, + string lessonVideoId, + string recruitId, + string videoId, + CancellationToken ct = default) + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var payload = JsonSerializer.Serialize(new + { + ccCourseId = courseId, + chapterId, + isApply = 1, + lessonId, + lessonVideoId, + recruitId, + videoId, + dateFormate = ts + }); + + var secretStr = AesEncrypt(payload, VideoKey); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr, + ["dateFormate"] = ts.ToString() + }); + + var client = CreateZhidaoClient(sessionData, Opt.StudyServiceBaseUrl); + var resp = await client.PostAsync( + $"{Opt.StudyServiceBaseUrl}/gateway/t/v1/learning/prelearningNote", + content, ct); + + var json = await ReadZhidaoJson(resp, ct); + var data = json.RootElement.TryGetProperty("data", out var d) ? d : default; + + var lessonDto = data.TryGetProperty("studiedLessonDto", out var sld) ? sld : default; + var studiedLessonId = GetStr(lessonDto, "id"); + var learningTokenId = string.IsNullOrWhiteSpace(studiedLessonId) + ? "" + : Convert.ToBase64String(Encoding.UTF8.GetBytes(studiedLessonId)); + var previousStudyTime = lessonDto.TryGetProperty("studyTotalTime", out var stt) ? stt.GetDouble() : 0; + + Console.WriteLine($"[Zhihuishu] prelearningNote: studiedLessonId={studiedLessonId}, previousStudyTime={previousStudyTime}s"); + + return new PrelearningResult + { + LearningTokenId = learningTokenId, + StudiedLessonId = studiedLessonId, + PreviousStudyTime = previousStudyTime + }; + } + + // ── Save Progress (step 2) ───────────────────────────── + + public async Task SaveDatabaseIntervalTimeV2Async( + PlatformSessionData sessionData, + string recruitId, + string lessonId, + string smallLessonId, + string videoId, + string chapterId, + string uuid, + double playedTime, // 累计播放时长(秒) + double lastIncrement, // 本次播放增量(秒) + string learningTokenId, + string courseId, + CancellationToken ct = default) + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // EV confusion algorithm + var evData = BuildEvData(recruitId, lessonId, smallLessonId, videoId, chapterId, + playedTime, lastIncrement, uuid); + + var payload = JsonSerializer.Serialize(new + { + ewssw = "0,1,2", + sdsew = GetEv(evData), + zwsds = learningTokenId, + courseId, + dateFormate = ts + }); + + var secretStr = AesEncrypt(payload, VideoKey); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secretStr"] = secretStr, + ["dateFormate"] = ts.ToString() + }); + + var client = CreateZhidaoClient(sessionData, Opt.StudyServiceBaseUrl); + var resp = await client.PostAsync( + $"{Opt.StudyServiceBaseUrl}/gateway/t/v1/learning/saveDatabaseIntervalTimeV2", + content, ct); + + var json = await ReadZhidaoJson(resp, ct); + var code = json.RootElement.TryGetProperty("code", out var c) ? c.GetInt32() : -1; + return code == 0; + } + + // ── Course Metadata ───────────────────────────────────── + + /// + /// Establish a studyservice-api session by going through CAS with the existing CASTGC. + /// Bypasses gologin (which returns 500) by directly using passport.zhihuishu.com/login?service= + /// + private async Task TryGologinAsync(PlatformSessionData sessionData, CancellationToken ct) + { + var cookieContainer = new CookieContainer(); + AddCookiesForDomain(cookieContainer, sessionData, Opt.PassportBaseUrl); + AddCookiesForDomain(cookieContainer, sessionData, Opt.OnlineServiceBaseUrl); + AddCookiesForDomain(cookieContainer, sessionData, Opt.StudyServiceBaseUrl); + + // Debug: check CASTGC presence + var passportCookies = cookieContainer.GetCookies(new Uri(Opt.PassportBaseUrl)); + var hasCastgc = false; + foreach (Cookie c in passportCookies) + if (c.Name == "CASTGC") { hasCastgc = true; Console.WriteLine($"[Zhihuishu] TryGologin: CASTGC found, value={Truncate(c.Value, 40)}"); } + if (!hasCastgc) Console.WriteLine("[Zhihuishu] TryGologin: CASTGC MISSING!"); + + using var handler = new HttpClientHandler + { + CookieContainer = cookieContainer, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10 + }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(Opt.TimeoutSeconds) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + client.DefaultRequestHeaders.Referrer = new Uri(Opt.StudyServiceBaseUrl); + + // Try gologin + var fromUrl = $"{Opt.StudyServiceBaseUrl}/gateway/t/v1/learning/videolist"; + var gologinUrl = $"{Opt.StudyServiceBaseUrl}/login/gologin?fromurl={Uri.EscapeDataString(fromUrl)}"; + Console.WriteLine($"[Zhihuishu] TryGologin GET {gologinUrl}"); + var resp = await client.GetAsync(gologinUrl, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + Console.WriteLine($"[Zhihuishu] TryGologin response: {resp.StatusCode}, body: {Truncate(body, 200)}"); + + if (resp.IsSuccessStatusCode) + { + MergeStudyserviceCookies(cookieContainer, sessionData); + return; + } + + throw new PlatformOperationException($"gologin 返回 {(int)resp.StatusCode}:{Truncate(body, 100)}"); + } + + private void MergeStudyserviceCookies(CookieContainer container, PlatformSessionData sessionData) + { + var uri = new Uri(Opt.StudyServiceBaseUrl); + var prefix = GetDomainPrefix(Opt.StudyServiceBaseUrl); + var cookies = container.GetCookies(uri); + Console.WriteLine($"[Zhihuishu] MergeStudyserviceCookies: got {cookies.Count} cookies"); + foreach (Cookie cookie in cookies) + { + sessionData.Cookies[$"{prefix}.{cookie.Name}"] = cookie.Value; + sessionData.Cookies[cookie.Name] = cookie.Value; + Console.WriteLine($"[Zhihuishu] + {cookie.Name}={Truncate(cookie.Value, 30)}"); + } + } + + /// Retrieve recruitId and ccCourseId for a given RAC_id from session data. + public static (string RecruitId, string CcCourseId) GetCourseMeta(PlatformSessionData sessionData, string racId) + { + var key = $"zhs_meta_{racId}"; + if (sessionData.Outputs.TryGetValue(key, out var json) && !string.IsNullOrWhiteSpace(json)) + { + try + { + using var doc = JsonDocument.Parse(json); + var recruitId = doc.RootElement.TryGetProperty("recruitId", out var r) ? r.GetString() ?? "" : ""; + var courseId = doc.RootElement.TryGetProperty("courseId", out var c) ? c.GetString() ?? "" : ""; + return (recruitId, courseId); + } + catch { } + } + return ("", ""); + } + + // ── Crypto Helpers ───────────────────────────────────── + + /// AES-CBC encryption for Zhidao API. + public static string AesEncrypt(string data, byte[] key) + { + // PKCS7 padding + var dataBytes = Encoding.UTF8.GetBytes(data); + var padLen = 16 - dataBytes.Length % 16; + var padded = new byte[dataBytes.Length + padLen]; + Array.Copy(dataBytes, padded, dataBytes.Length); + for (var i = dataBytes.Length; i < padded.Length; i++) + padded[i] = (byte)padLen; + + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = AesIv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + + using var encryptor = aes.CreateEncryptor(); + var encrypted = encryptor.TransformFinalBlock(padded, 0, padded.Length); + return Convert.ToBase64String(encrypted); + } + + /// AES-CBC decryption for Zhidao API responses (if needed). + public static string AesDecrypt(string encrypted, byte[] key) + { + var cipherBytes = Convert.FromBase64String(encrypted); + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = AesIv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + + using var decryptor = aes.CreateDecryptor(); + var decrypted = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + + // Remove PKCS7 padding + var padLen = decrypted[^1]; + if (padLen > 0 && padLen <= 16) + return Encoding.UTF8.GetString(decrypted, 0, decrypted.Length - padLen); + return Encoding.UTF8.GetString(decrypted); + } + + /// EV XOR confusion algorithm (ported from Python getEv). + public static string GetEv(List data, string key = "zzpttjd") + { + var dataStr = string.Join(";", data); + var keyGen = KeyCycle(key); + var ev = new StringBuilder(); + foreach (var c in dataStr) + { + var tmp = (c ^ keyGen()).ToString("x"); + if (tmp.Length < 2) tmp = "0" + tmp; + // Python's tmp[-4:] returns whole string for len<4; C# ^4 throws on short strings + ev.Append(tmp.Length <= 4 ? tmp : tmp[^4..]); + } + return ev.ToString(); + } + + private static Func KeyCycle(string key) + { + var keyChars = key.ToCharArray(); + var idx = new int[] { 0 }; + return () => + { + var result = (int)keyChars[idx[0]]; + idx[0] = (idx[0] + 1) % keyChars.Length; + return result; + }; + } + + /// Build raw_ev parameter list for saveDatabaseIntervalTimeV2. + public static List BuildEvData( + string recruitId, string lessonId, string smallLessonId, + string videoId, string chapterId, double playedTime, + double lastIncrement, string uuid) + { + var totalSeconds = (int)playedTime; + var h = totalSeconds / 3600; + var m = (totalSeconds % 3600) / 60; + var s = totalSeconds % 60; + + return + [ + recruitId, + lessonId, + smallLessonId, + videoId, + chapterId, + "0", // studyStatus + ((int)lastIncrement).ToString(), // 本次播放时长 + ((int)playedTime).ToString(), // 累计播放时长 + $"{h:D2}:{m:D2}:{s:D2}", // HH:MM:SS + uuid + "zhs" // UUID后缀 + ]; + } + + /// Seconds to HH:MM:SS format. + public static string Hms(double totalSeconds) + { + var ts = (int)totalSeconds; + return $"{ts / 3600:D2}:{ts % 3600 / 60:D2}:{ts % 60:D2}"; + } + + /// MD5 signature for Hike API. + public static string HikeMd5(string uuid, string courseId, string fileId, + string studyTotalTime, string startWatchTime, string endWatchTime, + string startDate, string endDate) + { + var raw = HikeSalt + uuid + courseId + fileId + studyTotalTime + + startDate + endDate + endWatchTime + startWatchTime + uuid; + var hash = MD5.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexStringLower(hash); + } + + // ── HttpClient Helpers ───────────────────────────────── + + private HttpClient CreateZhidaoClient(PlatformSessionData sessionData, string baseUrl) + { + // Use a fresh HttpClient (not from factory) to avoid UOOC-specific configuration + var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = true }) + { + BaseAddress = new Uri(baseUrl), + Timeout = TimeSpan.FromSeconds(Opt.TimeoutSeconds) + }; + client.DefaultRequestHeaders.Clear(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json, text/plain, */*"); + client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", Opt.OnlineServiceBaseUrl); + client.DefaultRequestHeaders.Referrer = new Uri(Opt.OnlineServiceBaseUrl); + + // Build cookie header: include ALL cookies from all domains + var cookieParts = new List(); + var addedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + // 1. Domain-specific cookies from ALL domains (strip prefix) + foreach (var kv in sessionData.Cookies) + { + var dotIdx = kv.Key.IndexOf('.'); + if (dotIdx <= 0) continue; // not a domain-prefixed key + var cookieName = kv.Key[(dotIdx + 1)..]; + if (addedNames.Contains(cookieName)) continue; + cookieParts.Add($"{cookieName}={kv.Value}"); + addedNames.Add(cookieName); + } + + // 2. Flat cookies (no '.' in key) as fallback + foreach (var kv in sessionData.Cookies) + { + if (kv.Key.Contains('.') || string.IsNullOrWhiteSpace(kv.Value)) continue; + if (addedNames.Contains(kv.Key)) continue; + cookieParts.Add($"{kv.Key}={kv.Value}"); + addedNames.Add(kv.Key); + } + + if (cookieParts.Count > 0) + { + var cookieHeader = string.Join("; ", cookieParts); + client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", cookieHeader); + var domainPrefix = GetDomainPrefix(baseUrl); + Console.WriteLine($"[Zhihuishu] Cookies for {domainPrefix}: {Truncate(cookieHeader, 250)}"); + } + else + { + Console.WriteLine($"[Zhihuishu] WARNING: No cookies found!"); + } + + return client; + } + + private static string GetDomainPrefix(string url) + { + var uri = new Uri(url); + return uri.Host.Split('.')[0]; // "onlineservice-api", "studyservice-api", etc. + } + + private static void AddCookiesForDomain(CookieContainer container, PlatformSessionData sessionData, string baseUrl) + { + var uri = new Uri(baseUrl); + var prefix = GetDomainPrefix(baseUrl); + + // Add domain-prefixed cookies + foreach (var kv in sessionData.Cookies) + { + if (kv.Key.StartsWith($"{prefix}.", StringComparison.OrdinalIgnoreCase)) + { + try { container.Add(uri, new Cookie(kv.Key[(prefix.Length + 1)..], kv.Value)); } + catch { /* duplicate */ } + } + } + + // Also add flat cookies (shared across domains like CASTGC, JSESSIONID) + foreach (var kv in sessionData.Cookies) + { + if (kv.Key.Contains('.') || string.IsNullOrWhiteSpace(kv.Value)) continue; + try { container.Add(uri, new Cookie(kv.Key, kv.Value)); } + catch { /* duplicate */ } + } + } + + private static async Task ReadZhidaoJson(HttpResponseMessage response, CancellationToken ct) + { + var text = await response.Content.ReadAsStringAsync(ct); + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + Console.WriteLine($"[Zhihuishu] 401/403: {Truncate(text, 200)}"); + throw new PlatformOperationException("智慧树会话已过期,请重新登录。", true); + } + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"[Zhihuishu] HTTP {(int)response.StatusCode}: {Truncate(text, 200)}"); + throw new PlatformOperationException($"智慧树接口返回 {(int)response.StatusCode}。"); + } + + JsonDocument doc; + try { doc = JsonDocument.Parse(text); } + catch + { + Console.WriteLine($"[Zhihuishu] JSON parse error: {Truncate(text, 200)}"); + throw new PlatformOperationException("智慧树接口返回了非预期的内容格式。"); + } + + var code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetInt32() : -1; + // Zhidao API returns code=200 or code=0 on success + if (code != 0 && code != 200) + { + var msg = doc.RootElement.TryGetProperty("message", out var m) + ? m.GetString() ?? "未知错误" + : "智慧树接口返回错误"; + Console.WriteLine($"[Zhihuishu] API error code={code}: {msg}"); + throw new PlatformOperationException(msg); + } + + return doc; + } + + private static string GetStr(JsonElement el, string prop) + { + if (el.ValueKind != JsonValueKind.Object) return ""; + if (!el.TryGetProperty(prop, out var v)) return ""; + return v.ValueKind switch + { + JsonValueKind.String => v.GetString() ?? "", + JsonValueKind.Number => v.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => "" + }; + } + + private static string Truncate(string value, int maxLen) => + value.Length <= maxLen ? value : value[..maxLen] + "..."; +} diff --git a/backend/src/UoocProgress.Api/UoocProgress.Api.csproj b/backend/src/UoocProgress.Api/UoocProgress.Api.csproj new file mode 100644 index 0000000..53775cc --- /dev/null +++ b/backend/src/UoocProgress.Api/UoocProgress.Api.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/backend/src/UoocProgress.Api/appsettings.Development.json b/backend/src/UoocProgress.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/backend/src/UoocProgress.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/src/UoocProgress.Api/appsettings.json b/backend/src/UoocProgress.Api/appsettings.json new file mode 100644 index 0000000..ea962b3 --- /dev/null +++ b/backend/src/UoocProgress.Api/appsettings.json @@ -0,0 +1,39 @@ +{ + "ConnectionStrings": { + "Default": "server=192.168.5.100;port=3306;database=uooc;user=product;password=123456;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Jwt": { + "Issuer": "UoocProgress", + "Audience": "UoocProgressClient", + "SigningKey": "please-change-this-signing-key-at-least-32-chars", + "ExpiresMinutes": 720 + }, + "BootstrapAdmin": { + "Username": "admin", + "DisplayName": "系统管理员", + "Password": "Admin123!" + }, + "Uooc": { + "BaseUrl": "https://www.uooconline.com", + "TimeoutSeconds": 15 + }, + "Zhihuishu": { + "PassportBaseUrl": "https://passport.zhihuishu.com", + "OnlineServiceBaseUrl": "https://onlineservice-api.zhihuishu.com", + "StudyServiceBaseUrl": "https://studyservice-api.zhihuishu.com", + "NewbaseUrl": "https://newbase.zhihuishu.com", + "AppcommUserBaseUrl": "https://appcomm-user.zhihuishu.com", + "HikeServiceBaseUrl": "https://hikeservice.zhihuishu.com", + "StudyResourcesBaseUrl": "https://studyresources.zhihuishu.com", + "HikeTeachingBaseUrl": "https://hike-teaching.zhihuishu.com", + "CasServiceUrl": "https://onlineservice-api.zhihuishu.com/gateway/t/v1/student/course/share/queryShareCourseInfo", + "TimeoutSeconds": 30 + } +} diff --git a/backend/src/UoocProgress.Node/Program.cs b/backend/src/UoocProgress.Node/Program.cs new file mode 100644 index 0000000..fd8910a --- /dev/null +++ b/backend/src/UoocProgress.Node/Program.cs @@ -0,0 +1,161 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Playwright; + +// ── Config (read from file or prompt once) ── +var configPath = Path.Combine(AppContext.BaseDirectory, "node-config.json"); +NodeConfig config; + +if (File.Exists(configPath)) +{ + config = JsonSerializer.Deserialize(File.ReadAllText(configPath))!; + Console.WriteLine($"从 {configPath} 加载配置"); +} +else +{ + Console.Write("后端地址 (例 http://192.168.1.100:5088): "); + var url = Console.ReadLine()?.Trim() ?? "http://localhost:5088"; + Console.Write("节点名称 (例 书房电脑): "); + var name = Console.ReadLine()?.Trim() ?? "Unnamed"; + Console.Write("Token: "); + var token = Console.ReadLine()?.Trim() ?? ""; + + config = new NodeConfig { BackendUrl = url.TrimEnd('/'), Name = name, Token = token }; + File.WriteAllText(configPath, JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true })); + Console.WriteLine($"配置已保存到 {configPath}"); +} + +var http = new HttpClient { BaseAddress = new Uri(config.BackendUrl + "/") }; +http.Timeout = TimeSpan.FromSeconds(15); + +// ── Register ── +var regResp = await http.PostAsJsonAsync("/api/node/register", new { config.Name, config.Token }); +regResp.EnsureSuccessStatusCode(); +var reg = await regResp.Content.ReadFromJsonAsync(); +var nodeId = reg!.NodeId; +Console.WriteLine($"已注册为节点 #{nodeId} ({config.Name})"); + +// ── Background heartbeat ── +_ = Task.Run(async () => +{ + while (true) { try { await http.PostAsJsonAsync("/api/node/heartbeat", new { NodeId = nodeId }); } catch { } await Task.Delay(10000); } +}); + +// ── Main poll loop ── +while (true) +{ + try + { + var pollResp = await http.GetAsync($"/api/node/poll?nodeId={nodeId}"); + var pollJson = await pollResp.Content.ReadAsStringAsync(); + var poll = JsonSerializer.Deserialize(pollJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (poll?.Task is null) { await Task.Delay(3000); continue; } + + var t = poll.Task; + Console.WriteLine($"接到任务: {t.CourseName} ({t.TotalSteps} 步)"); + await ExecuteTask(http, nodeId, t); + Console.WriteLine("任务结束,等待下一个..."); + } + catch (Exception ex) { Console.WriteLine($"轮询错误: {ex.Message}"); await Task.Delay(5000); } +} + +async Task ExecuteTask(HttpClient client, long id, TaskInfo task) +{ + try + { + var data = JsonSerializer.Deserialize(task.TaskDataJson)!; + var chapters = data.Chapters ?? []; + var autoSteps = data.AutomationSteps ?? []; + + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new() { Headless = false }); + var context = await browser.NewContextAsync(); + var page = await context.NewPageAsync(); + + // Execute configured automation steps (login, navigate, etc.) + foreach (var a in autoSteps) + { + Console.WriteLine($" Automation: {a.Action}"); + try + { + switch (a.Action) + { + case "navigate": + await page.GotoAsync(a.Url ?? task.PlatformUrl); + break; + case "click": + if (a.Selector is not null) + { + await page.WaitForSelectorAsync(a.Selector, new() { Timeout = 10000 }); + await page.ClickAsync(a.Selector); + } + break; + case "wait_selector": + if (a.Selector is not null) + await page.WaitForSelectorAsync(a.Selector, new() { Timeout = (a.Timeout > 0 ? a.Timeout : 30) * 1000 }); + break; + case "wait_seconds": + await Task.Delay((a.Seconds > 0 ? a.Seconds : 5) * 1000); + break; + case "scroll": + await page.EvaluateAsync($"window.scrollBy(0, {a.Pixels})"); + break; + case "fill": + if (a.Selector is not null) + await page.FillAsync(a.Selector, a.Value ?? ""); + break; + } + } + catch (Exception ex) { Console.WriteLine($" Step failed: {ex.Message}"); } + } + + // Process chapters/sections/URLs + var step = 0; + foreach (var ch in chapters) + { + foreach (var sec in ch.Sections) + { + foreach (var url in sec.Urls) + { + step++; + Console.WriteLine($"[{step}/{task.TotalSteps}] {ch.ChapterName}/{sec.SectionName}: {url}"); + try { await page.GotoAsync(url); await Task.Delay(2000); } catch { } + + for (var w = 0; w < 120; w++) + { + await Task.Delay(10000); + await client.PostAsJsonAsync("/api/node/progress", new + { + TaskId = task.TaskId, CompletedSteps = step, + CurrentStep = $"{ch.ChapterName} / {sec.SectionName}", + LastError = (string?)null + }); + } + } + } + } + + await client.PostAsJsonAsync("/api/node/complete", new { TaskId = task.TaskId }); + Console.WriteLine("任务完成!"); + await browser.CloseAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"任务失败: {ex.Message}"); + try { await client.PostAsJsonAsync("/api/node/fail", new { TaskId = task.TaskId, Error = ex.Message }); } catch { } + } +} + +// ── Types ── +public sealed record NodeConfig { public string BackendUrl { get; set; } = ""; public string Name { get; set; } = ""; public string Token { get; set; } = ""; } +public sealed record RegisterResponse(long NodeId); +public sealed record PollResponse(TaskInfo? Task); +public sealed record TaskInfo(long TaskId, string CourseId, string CourseName, string PlatformUrl, string TaskDataJson, int TotalSteps); +public sealed record ChapterData(string ChapterName, List Sections); +public sealed record SectionData(string SectionName, List Urls); + +// Task payload (same structure as what backend puts in TaskDataJson) +public sealed record TaskPayload(List? Chapters, List? AutomationSteps); +public sealed record AutomationStep(string Action, string? Url, string? Selector, int Timeout, int Seconds, int Pixels, string? Value); diff --git a/backend/src/UoocProgress.Node/UoocProgress.Node.csproj b/backend/src/UoocProgress.Node/UoocProgress.Node.csproj new file mode 100644 index 0000000..58f3957 --- /dev/null +++ b/backend/src/UoocProgress.Node/UoocProgress.Node.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8e5d899 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:5088 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4b8bbcc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 学习进度平台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ebde240 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1938 @@ +{ + "name": "uooc-progress-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uooc-progress-frontend", + "version": "0.1.0", + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "ant-design-vue": "^4.2.6", + "pinia": "^3.0.3", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.8.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@ant-design/colors": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-6.0.0.tgz", + "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/icons-vue": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz", + "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-svg": "^4.2.1" + }, + "peerDependencies": { + "vue": ">=3.0.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simonwep/pickr": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.8.2.tgz", + "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==", + "license": "MIT", + "dependencies": { + "core-js": "^3.15.1", + "nanopop": "^2.1.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ant-design-vue": { + "version": "4.2.6", + "resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-4.2.6.tgz", + "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-vue": "^7.0.0", + "@babel/runtime": "^7.10.5", + "@ctrl/tinycolor": "^3.5.0", + "@emotion/hash": "^0.9.0", + "@emotion/unitless": "^0.8.0", + "@simonwep/pickr": "~1.8.0", + "array-tree-filter": "^2.1.0", + "async-validator": "^4.0.0", + "csstype": "^3.1.1", + "dayjs": "^1.10.5", + "dom-align": "^1.12.1", + "dom-scroll-into-view": "^2.0.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.15", + "resize-observer-polyfill": "^1.5.1", + "scroll-into-view-if-needed": "^2.2.25", + "shallow-equal": "^1.0.0", + "stylis": "^4.1.3", + "throttle-debounce": "^5.0.0", + "vue-types": "^3.0.0", + "warning": "^4.0.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design-vue" + }, + "peerDependencies": { + "vue": ">=3.2.0" + } + }, + "node_modules/array-tree-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==", + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, + "node_modules/dom-scroll-into-view": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz", + "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-plain-object": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-3.0.1.tgz", + "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanopop": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.4.2.tgz", + "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } + }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/vue-types/-/vue-types-3.0.2.tgz", + "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==", + "license": "MIT", + "dependencies": { + "is-plain-object": "3.0.1" + }, + "engines": { + "node": ">=10.15.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cf0ca21 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "uooc-progress-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "ant-design-vue": "^4.2.6", + "pinia": "^3.0.3", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.8.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.10" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..eb48faf --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/UoocLoginModal.vue b/frontend/src/components/UoocLoginModal.vue new file mode 100644 index 0000000..f40889d --- /dev/null +++ b/frontend/src/components/UoocLoginModal.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/src/components/ZhihuishuLoginModal.vue b/frontend/src/components/ZhihuishuLoginModal.vue new file mode 100644 index 0000000..6eff2c6 --- /dev/null +++ b/frontend/src/components/ZhihuishuLoginModal.vue @@ -0,0 +1,168 @@ + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/layouts/AppShellLayout.vue b/frontend/src/layouts/AppShellLayout.vue new file mode 100644 index 0000000..63bae35 --- /dev/null +++ b/frontend/src/layouts/AppShellLayout.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..fc3a2c0 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,402 @@ +import { readText, storageKeys } from './storage'; +import type { + AuthTokenResponse, + AuthUserDto, + BrushStatusDto, + CatalogResponse, + ChallengeSessionDto, + ChangePasswordRequest, + CourseOptionsResponse, + CourseProgressResponse, + UnitsResponse, + CreateInviteCodeRequest, + InviteCodeDto, + LoginRequest, + PlatformConnectionDto, + PlatformCourseQueryRequest, + PlatformDefinitionDto, + PlatformLoginStartRequest, + PlatformLoginStartResponse, + PlatformReloginRequest, + PlatformSchemaDto, + PlatformStatusPatchRequest, + PlatformSummaryDto, + ProblemDetails, + PublicAuthConfigResponse, + RegisterRequest, + SavePlatformDefinitionRequest, + SystemSettingDto, + UpdateInviteCodeRequest, + UpdateSystemSettingRequest, + UpdateUserRequest, + UoocLoginRequest, + UoocLoginResponse, + ZhihuishuLoginRequest, + ZhihuishuLoginResponse, +} from '../types/api'; + +const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, ''); + +type AuthErrorKind = 'system' | 'platform' | 'unknown'; + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + body?: unknown; + skipAuth?: boolean; +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly kind: AuthErrorKind = 'unknown', + ) { + super(message); + } +} + +async function requestJson(path: string, options: RequestOptions = {}): Promise { + const headers = new Headers(); + + if (!options.skipAuth) { + const accessToken = readText(storageKeys.accessToken); + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + } + + let body: string | undefined; + if (options.body !== undefined) { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(options.body); + } + + const response = await fetch(`${apiBaseUrl}${path}`, { + method: options.method ?? 'GET', + headers, + body, + }); + + if (!response.ok) { + const problem = await readProblemDetails(response); + const authKind = (response.headers.get('X-Auth-Error') ?? 'unknown') as AuthErrorKind; + throw new ApiError( + problem.detail || problem.title || `Request failed with status ${response.status}.`, + response.status, + authKind, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +async function readProblemDetails(response: Response): Promise { + try { + return (await response.json()) as ProblemDetails; + } catch { + return { + title: response.statusText, + status: response.status, + }; + } +} + +export function getAuthConfig(): Promise { + return requestJson('/api/public/auth-config', { skipAuth: true }); +} + +export function registerUser(payload: RegisterRequest): Promise { + return requestJson('/api/auth/register', { + method: 'POST', + body: payload, + skipAuth: true, + }); +} + +export function sendEmailCode(email: string): Promise { + return requestJson('/api/auth/send-email-code', { + method: 'POST', + body: { email }, + skipAuth: true, + }); +} + +export function loginUser(payload: LoginRequest): Promise { + return requestJson('/api/auth/login', { + method: 'POST', + body: payload, + skipAuth: true, + }); +} + +export function getCurrentUser(): Promise { + return requestJson('/api/auth/me'); +} + +export function changePassword(payload: ChangePasswordRequest): Promise { + return requestJson('/api/auth/change-password', { + method: 'POST', + body: payload, + }); +} + +export function getAdminUsers(): Promise { + return requestJson('/api/admin/users'); +} + +export function updateAdminUser(userId: number, payload: UpdateUserRequest): Promise { + return requestJson(`/api/admin/users/${userId}`, { + method: 'PATCH', + body: payload, + }); +} + +export function getInviteCodes(): Promise { + return requestJson('/api/admin/invites'); +} + +export function createInviteCode(payload: CreateInviteCodeRequest): Promise { + return requestJson('/api/admin/invites', { + method: 'POST', + body: payload, + }); +} + +export function updateInviteCode(inviteId: number, payload: UpdateInviteCodeRequest): Promise { + return requestJson(`/api/admin/invites/${inviteId}`, { + method: 'PATCH', + body: payload, + }); +} + +export function getSystemSettings(): Promise { + return requestJson('/api/admin/settings'); +} + +export function updateSystemSettings(payload: UpdateSystemSettingRequest): Promise { + return requestJson('/api/admin/settings', { + method: 'PUT', + body: payload, + }); +} + +export function getAdminPlatforms(): Promise { + return requestJson('/api/admin/platforms'); +} + +export function createAdminPlatform(payload: SavePlatformDefinitionRequest): Promise { + return requestJson('/api/admin/platforms', { + method: 'POST', + body: payload, + }); +} + +export function getAdminPlatform(platformId: number): Promise { + return requestJson(`/api/admin/platforms/${platformId}`); +} + +export function updateAdminPlatform(platformId: number, payload: SavePlatformDefinitionRequest): Promise { + return requestJson(`/api/admin/platforms/${platformId}`, { + method: 'PUT', + body: payload, + }); +} + +export function updateAdminPlatformStatus(platformId: number, payload: PlatformStatusPatchRequest): Promise { + return requestJson(`/api/admin/platforms/${platformId}/status`, { + method: 'PATCH', + body: payload, + }); +} + +export function cloneAdminPlatform(platformId: number): Promise { + return requestJson(`/api/admin/platforms/${platformId}/clone`, { + method: 'POST', + }); +} + +export function getPlatforms(): Promise { + return requestJson('/api/platforms'); +} + +export function getPlatformLoginSchema(platformId: number): Promise { + return requestJson(`/api/platforms/${platformId}/schemas/login`); +} + +export function getPlatformCourseQuerySchema(platformId: number): Promise { + return requestJson(`/api/platforms/${platformId}/schemas/course-query`); +} + +export function getPlatformConnections(): Promise { + return requestJson('/api/platform-connections'); +} + +export function createPlatformConnection(payload: PlatformLoginStartRequest): Promise { + return requestJson('/api/platform-connections', { + method: 'POST', + body: payload, + }); +} + +export function reloginPlatformConnection(connectionId: number, payload: PlatformReloginRequest): Promise { + return requestJson(`/api/platform-connections/${connectionId}/relogin`, { + method: 'POST', + body: payload, + }); +} + +export function uoocLogin(payload: UoocLoginRequest): Promise { + return requestJson('/api/platform-connections/uooc-login', { + method: 'POST', + body: payload, + }); +} + +export function zhihuishuLogin(payload: ZhihuishuLoginRequest): Promise { + return requestJson('/api/platform-connections/zhihuishu-login', { + method: 'POST', + body: payload, + }); +} + +export function activatePlatformConnection(connectionId: number): Promise { + return requestJson(`/api/platform-connections/${connectionId}/activate`, { + method: 'POST', + }); +} + +export function deletePlatformConnection(connectionId: number): Promise { + return requestJson(`/api/platform-connections/${connectionId}`, { + method: 'DELETE', + }); +} + +export function getPlatformChallenge(challengeSessionId: string): Promise { + return requestJson(`/api/platform-challenges/${challengeSessionId}`); +} + +export function queryPlatformCourses(connectionId: number, payload: PlatformCourseQueryRequest): Promise { + return requestJson(`/api/platform-connections/${connectionId}/courses/query`, { + method: 'POST', + body: payload, + }); +} + +export function getPlatformCatalog(connectionId: number, courseId: string): Promise { + const search = new URLSearchParams({ courseId }); + return requestJson(`/api/platform-connections/${connectionId}/catalog?${search.toString()}`); +} + +export function getPlatformProgress(connectionId: number, courseId: string): Promise { + const search = new URLSearchParams({ courseId }); + return requestJson(`/api/platform-connections/${connectionId}/progress?${search.toString()}`); +} + +export function getSectionUnits( + connectionId: number, + courseId: string, + chapterId: string, + sectionId: string, +): Promise { + const search = new URLSearchParams({ courseId, chapterId, sectionId }); + return requestJson(`/api/platform-connections/${connectionId}/units?${search.toString()}`); +} + +export function startBrush(payload: StartBrushRequest): Promise { + return requestJson('/api/brush/start', { method: 'POST', body: payload }); +} + +export function getBrushStatus(): Promise { + return requestJson('/api/brush/status'); +} + +export function stopBrush(): Promise { + return requestJson('/api/brush/stop', { method: 'POST' }); +} + +export function retryBrush(): Promise { + return requestJson('/api/brush/retry', { method: 'POST' }); +} + +export function stopBrushTask(taskId: string): Promise { + return requestJson(`/api/brush/${encodeURIComponent(taskId)}/stop`, { method: 'POST' }); +} + +export function retryBrushTask(taskId: string): Promise { + return requestJson(`/api/brush/${encodeURIComponent(taskId)}/retry`, { method: 'POST' }); +} + +export function deleteBrushTask(taskId: string): Promise { + return requestJson(`/api/brush/${encodeURIComponent(taskId)}`, { method: 'DELETE' }); +} + +// Admin brush APIs +export function getAdminBrushTasks(): Promise { + return requestJson('/api/admin/brush/tasks'); +} + +export function adminStopBrushTask(userId: number): Promise { + return requestJson(`/api/admin/brush/stop/${userId}`, { method: 'POST' }); +} + +export function adminStopAllBrushTasks(): Promise { + return requestJson('/api/admin/brush/stop-all', { method: 'POST' }); +} + +export function getAdminBrushConfig(): Promise<{ pauseNewTasks: boolean }> { + return requestJson('/api/admin/brush/config'); +} + +export function updateAdminBrushConfig(config: { pauseNewTasks: boolean }): Promise<{ pauseNewTasks: boolean }> { + return requestJson('/api/admin/brush/config', { method: 'PUT', body: config }); +} + +export interface AdminBrushTaskDto { + userId: number; + status: string; + totalVideos: number; + completedVideos: number; + currentChapterName: string; + currentSectionName: string; + currentVideoTitle: string; + currentVideoPos: number; + currentVideoLength: number; + lastError: string | null; + retryCount: number; +} + +// Node management +export function getNodes(): Promise { return requestJson('/api/admin/nodes'); } +export function generateNodeToken(): Promise<{ token: string }> { return requestJson('/api/admin/nodes/token', { method: 'POST' }); } +export function deleteNode(nodeId: number): Promise { return requestJson(`/api/admin/nodes/${nodeId}`, { method: 'DELETE' }); } +export function getNodeTasks(): Promise { return requestJson('/api/admin/nodes/tasks'); } +export function cancelNodeTask(taskId: number): Promise { return requestJson(`/api/admin/nodes/tasks/${taskId}/cancel`, { method: 'POST' }); } + +export interface StartBrushRequest { + courseId: string; + chapters: ChapterBrushInput[]; +} + +export interface ChapterBrushInput { + chapterId: string; + chapterName: string; + sections: SectionBrushInput[]; +} + +export interface SectionBrushInput { + sectionId: string; + sectionName: string; + videos: VideoBrushInput[]; +} + +export interface VideoBrushInput { + resourceId: string; + title: string; + sectionCatalogId: string; + cdnUrl: string; + videoLength: number; +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..1bbd300 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,60 @@ +const isBrowser = typeof window !== 'undefined'; + +const rootKey = 'uooc-progress'; + +export const storageKeys = { + accessToken: `${rootKey}:access-token`, + selectedPlatformId: (userId: number) => `${rootKey}:user:${userId}:selected-platform-id`, + selectedCourseIds: (userId: number) => `${rootKey}:user:${userId}:selected-course-map`, + courseOptions: (userId: number) => `${rootKey}:user:${userId}:course-options`, + progressSnapshots: (userId: number) => `${rootKey}:user:${userId}:progress-snapshots`, +}; + +export function readText(key: string, fallback = ''): string { + if (!isBrowser) { + return fallback; + } + + return window.localStorage.getItem(key) ?? fallback; +} + +export function writeText(key: string, value: string): void { + if (!isBrowser) { + return; + } + + window.localStorage.setItem(key, value); +} + +export function removeKey(key: string): void { + if (!isBrowser) { + return; + } + + window.localStorage.removeItem(key); +} + +export function readJson(key: string, fallback: T): T { + if (!isBrowser) { + return fallback; + } + + const raw = window.localStorage.getItem(key); + if (!raw) { + return fallback; + } + + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export function writeJson(key: string, value: T): void { + if (!isBrowser) { + return; + } + + window.localStorage.setItem(key, JSON.stringify(value)); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..ab8d320 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,23 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import Antd from 'ant-design-vue'; + +import App from './App.vue'; +import router from './router'; +import { useAuthStore } from './stores/auth'; + +async function bootstrap() { + const app = createApp(App); + const pinia = createPinia(); + + app.use(pinia); + app.use(Antd); + + const authStore = useAuthStore(pinia); + await authStore.bootstrap(); + + app.use(router); + app.mount('#app'); +} + +void bootstrap(); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..5c372fa --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,202 @@ +import { createRouter, createWebHistory } from 'vue-router'; + +import AppShellLayout from '../layouts/AppShellLayout.vue'; +import AdminInvitesView from '../views/admin/AdminInvitesView.vue'; +import AdminPlatformsView from '../views/admin/AdminPlatformsView.vue'; +import AdminTasksView from '../views/admin/AdminTasksView.vue'; +import AdminNodesView from '../views/admin/AdminNodesView.vue'; +import AdminSettingsView from '../views/admin/AdminSettingsView.vue'; +import AdminUsersView from '../views/admin/AdminUsersView.vue'; +import ChangePasswordView from '../views/ChangePasswordView.vue'; +import CourseSelectionView from '../views/CourseSelectionView.vue'; +import ForbiddenView from '../views/ForbiddenView.vue'; +import PlatformConnectionsView from '../views/PlatformConnectionsView.vue'; +import ProfileView from '../views/ProfileView.vue'; +import ProgressView from '../views/ProgressView.vue'; +import LoginView from '../views/auth/LoginView.vue'; +import RegisterView from '../views/auth/RegisterView.vue'; +import { useAuthStore } from '../stores/auth'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + redirect: '/courses', + }, + { + path: '/login', + name: 'login', + component: LoginView, + meta: { + publicOnly: true, + title: '系统登录', + }, + }, + { + path: '/register', + name: 'register', + component: RegisterView, + meta: { + publicOnly: true, + title: '注册账号', + }, + }, + { + path: '/', + component: AppShellLayout, + meta: { + requiresAuth: true, + }, + children: [ + { + path: 'courses', + name: 'courses', + component: CourseSelectionView, + meta: { + requiresAuth: true, + title: '刷课页', + }, + }, + { + path: 'progress', + name: 'progress', + component: ProgressView, + meta: { + requiresAuth: true, + title: '查询进度', + }, + }, + { + path: 'platform-connections', + name: 'platform-connections', + component: PlatformConnectionsView, + meta: { + requiresAuth: true, + title: '平台连接', + }, + }, + { + path: 'profile', + name: 'profile', + component: ProfileView, + meta: { + requiresAuth: true, + title: '个人信息', + }, + }, + { + path: 'profile/password', + name: 'profile-password', + component: ChangePasswordView, + meta: { + requiresAuth: true, + title: '修改密码', + }, + }, + { + path: 'admin/users', + name: 'admin-users', + component: AdminUsersView, + meta: { + requiresAuth: true, + requiresAdmin: true, + title: '用户管理', + }, + }, + { + path: 'admin/invites', + name: 'admin-invites', + component: AdminInvitesView, + meta: { + requiresAuth: true, + requiresAdmin: true, + title: '邀请码管理', + }, + }, + { + path: 'admin/settings', + name: 'admin-settings', + component: AdminSettingsView, + meta: { + requiresAuth: true, + requiresAdmin: true, + title: '系统设置', + }, + }, + { + path: 'admin/platforms', + name: 'admin-platforms', + component: AdminPlatformsView, + meta: { + requiresAuth: true, + requiresAdmin: true, + title: '平台管理', + }, + }, + { + path: 'admin/tasks', + name: 'admin-tasks', + component: AdminTasksView, + meta: { + requiresAuth: true, + requiresAdmin: true, + title: '任务监控', + }, + }, + { + path: 'admin/nodes', + name: 'admin-nodes', + component: AdminNodesView, + meta: { + requiresAuth: true, + requiresAdmin: true, + title: '节点管理', + }, + }, + { + path: 'forbidden', + name: 'forbidden', + component: ForbiddenView, + meta: { + requiresAuth: true, + title: '无权访问', + }, + }, + ], + }, + ], +}); + +router.beforeEach(async (to) => { + const authStore = useAuthStore(); + + if (!authStore.isReady) { + await authStore.bootstrap(); + } + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + return { + name: 'login', + query: { + redirect: to.fullPath, + }, + }; + } + + if (to.meta.publicOnly && authStore.isAuthenticated) { + return { + name: 'courses', + }; + } + + if (to.meta.requiresAdmin && !authStore.isAdmin) { + return { + name: 'forbidden', + }; + } + + return true; +}); + +export default router; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..c7b81b8 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,155 @@ +import { defineStore } from 'pinia'; + +import { ApiError, changePassword, getAuthConfig, getCurrentUser, loginUser, registerUser } from '../lib/api'; +import { readText, removeKey, storageKeys, writeText } from '../lib/storage'; +import type { AuthTokenResponse, AuthUserDto, PublicAuthConfigResponse, RegisterRequest } from '../types/api'; +import { useCoursesStore } from './courses'; +import { usePlatformStore } from './platform'; + +interface AuthState { + accessToken: string; + currentUser: AuthUserDto | null; + authConfig: PublicAuthConfigResponse | null; + isReady: boolean; + isBootstrapping: boolean; + authError: string; +} + +let bootstrapPromise: Promise | null = null; + +export const useAuthStore = defineStore('auth', { + state: (): AuthState => ({ + accessToken: readText(storageKeys.accessToken), + currentUser: null, + authConfig: null, + isReady: false, + isBootstrapping: false, + authError: '', + }), + getters: { + isAuthenticated(state): boolean { + return Boolean(state.accessToken && state.currentUser); + }, + isAdmin(state): boolean { + return state.currentUser?.role === 'admin'; + }, + systemName(state): string { + return state.authConfig?.systemName ?? 'UOOC Progress'; + }, + requireEmailVerification(state): boolean { + return state.authConfig?.requireEmailVerification ?? false; + }, + registrationMode(state): 'open' | 'invite_only' { + return state.authConfig?.registrationMode ?? 'open'; + }, + displayInitial(state): string { + return state.currentUser?.displayName?.slice(0, 1).toUpperCase() || 'U'; + }, + roleLabel(state): string { + return state.currentUser?.role === 'admin' ? '管理员' : '普通用户'; + }, + }, + actions: { + async bootstrap(): Promise { + if (this.isReady) { + return; + } + + if (bootstrapPromise) { + return bootstrapPromise; + } + + this.isBootstrapping = true; + bootstrapPromise = (async () => { + await this.loadAuthConfig(); + + const token = readText(storageKeys.accessToken); + this.accessToken = token; + if (token) { + try { + const user = await getCurrentUser(); + this.currentUser = user; + this.initializeUserScopedState(user.id); + } catch (error) { + this.handleSystemUnauthorized(error instanceof Error ? error.message : '系统登录已失效。'); + } + } + + this.isReady = true; + this.isBootstrapping = false; + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + }, + async loadAuthConfig(): Promise { + try { + this.authConfig = await getAuthConfig(); + } catch { + this.authConfig = { registrationMode: 'open', systemName: 'UOOC Progress', requireEmailVerification: false }; + } + }, + async login(username: string, password: string): Promise { + this.authError = ''; + + try { + const response = await loginUser({ + username: username.trim(), + password: password.trim(), + }); + this.applyAuth(response); + } catch (error) { + this.authError = error instanceof Error ? error.message : '登录失败,请稍后重试。'; + throw error; + } + }, + async register(payload: RegisterRequest): Promise { + this.authError = ''; + + try { + const response = await registerUser(payload); + this.applyAuth(response); + } catch (error) { + this.authError = error instanceof Error ? error.message : '注册失败,请稍后重试。'; + throw error; + } + }, + async updatePassword(currentPassword: string, newPassword: string): Promise { + await changePassword({ currentPassword, newPassword }); + }, + applyAuth(response: AuthTokenResponse): void { + this.accessToken = response.accessToken; + this.currentUser = response.user; + this.authError = ''; + writeText(storageKeys.accessToken, response.accessToken); + this.initializeUserScopedState(response.user.id); + }, + initializeUserScopedState(userId: number): void { + usePlatformStore().restoreForUser(userId); + useCoursesStore().restoreForUser(userId); + }, + logout(): void { + this.accessToken = ''; + this.currentUser = null; + this.authError = ''; + removeKey(storageKeys.accessToken); + usePlatformStore().resetInMemory(); + useCoursesStore().resetInMemory(); + }, + handleSystemUnauthorized(message = '系统登录已失效,请重新登录。'): void { + this.logout(); + this.authError = message; + }, + handleApiError(error: unknown): string { + if (error instanceof ApiError && error.kind === 'system') { + this.handleSystemUnauthorized('系统登录已失效,请重新登录。'); + return '系统登录已失效,请重新登录。'; + } + + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; + }, + }, +}); diff --git a/frontend/src/stores/courses.ts b/frontend/src/stores/courses.ts new file mode 100644 index 0000000..33a9d4d --- /dev/null +++ b/frontend/src/stores/courses.ts @@ -0,0 +1,144 @@ +import { defineStore } from 'pinia'; + +import { ApiError, getPlatformProgress, queryPlatformCourses } from '../lib/api'; +import { readJson, storageKeys, writeJson } from '../lib/storage'; +import type { CourseOptionDto, CourseOptionsResponse, CourseProgressResponse } from '../types/api'; +import { useAuthStore } from './auth'; + +interface CourseState { + userId: number | null; + courseOptionsByConnection: Record; + selectedCourseByConnection: Record; + progressSnapshots: Record; + loadingConnections: string[]; + loadingProgressKeys: string[]; + errorByConnection: Record; + messageByConnection: Record; +} + +export const useCoursesStore = defineStore('courses', { + state: (): CourseState => ({ + userId: null, + courseOptionsByConnection: {}, + selectedCourseByConnection: {}, + progressSnapshots: {}, + loadingConnections: [], + loadingProgressKeys: [], + errorByConnection: {}, + messageByConnection: {}, + }), + actions: { + restoreForUser(userId: number): void { + this.userId = userId; + this.courseOptionsByConnection = readJson(storageKeys.courseOptions(userId), {}); + this.selectedCourseByConnection = readJson(storageKeys.selectedCourseIds(userId), {}); + this.progressSnapshots = readJson(storageKeys.progressSnapshots(userId), {}); + this.loadingConnections = []; + this.loadingProgressKeys = []; + this.errorByConnection = {}; + this.messageByConnection = {}; + }, + resetInMemory(): void { + this.userId = null; + this.courseOptionsByConnection = {}; + this.selectedCourseByConnection = {}; + this.progressSnapshots = {}; + this.loadingConnections = []; + this.loadingProgressKeys = []; + this.errorByConnection = {}; + this.messageByConnection = {}; + }, + persist(): void { + if (!this.userId) { + return; + } + + writeJson(storageKeys.courseOptions(this.userId), this.courseOptionsByConnection); + writeJson(storageKeys.selectedCourseIds(this.userId), this.selectedCourseByConnection); + writeJson(storageKeys.progressSnapshots(this.userId), this.progressSnapshots); + }, + getSelectedCourseId(connectionId: number | null | undefined): string { + if (!connectionId) { + return ''; + } + + return this.selectedCourseByConnection[String(connectionId)] ?? ''; + }, + getCourseOptions(connectionId: number | null | undefined): CourseOptionDto[] { + if (!connectionId) { + return []; + } + + return this.courseOptionsByConnection[String(connectionId)] ?? []; + }, + getProgress(connectionId: number | null | undefined, courseId: string): CourseProgressResponse | null { + if (!connectionId || !courseId) { + return null; + } + + return this.progressSnapshots[`${connectionId}:${courseId}`] ?? null; + }, + setSelectedCourse(connectionId: number, courseId: string): void { + this.selectedCourseByConnection[String(connectionId)] = courseId; + this.persist(); + }, + async queryCourses(connectionId: number, fields: Record): Promise { + const key = String(connectionId); + if (!this.loadingConnections.includes(key)) { + this.loadingConnections.push(key); + } + + try { + const response = await queryPlatformCourses(connectionId, { fields }); + this.courseOptionsByConnection[key] = response.items; + this.messageByConnection[key] = response.message ?? ''; + delete this.errorByConnection[key]; + + if (response.items.length > 0 && !this.selectedCourseByConnection[key]) { + this.selectedCourseByConnection[key] = response.items[0].value; + } + + this.persist(); + return response; + } catch (error) { + this.errorByConnection[key] = this.resolveApiError(error, '课程列表加载失败。'); + throw error; + } finally { + this.loadingConnections = this.loadingConnections.filter((item) => item !== key); + } + }, + async refreshProgress(connectionId: number, courseId: string): Promise { + const key = `${connectionId}:${courseId}`; + if (!this.loadingProgressKeys.includes(key)) { + this.loadingProgressKeys.push(key); + } + + try { + const response = await getPlatformProgress(connectionId, courseId); + this.progressSnapshots[key] = response; + delete this.errorByConnection[String(connectionId)]; + this.persist(); + return response; + } catch (error) { + this.errorByConnection[String(connectionId)] = this.resolveApiError(error, '进度刷新失败。'); + throw error; + } finally { + this.loadingProgressKeys = this.loadingProgressKeys.filter((item) => item !== key); + } + }, + isLoadingCourses(connectionId: number | null | undefined): boolean { + return connectionId ? this.loadingConnections.includes(String(connectionId)) : false; + }, + isLoadingProgress(connectionId: number | null | undefined, courseId: string): boolean { + return connectionId ? this.loadingProgressKeys.includes(`${connectionId}:${courseId}`) : false; + }, + resolveApiError(error: unknown, fallback: string): string { + if (error instanceof ApiError && error.kind === 'system') { + useAuthStore().handleSystemUnauthorized('系统登录已失效,请重新登录。'); + return '系统登录已失效,请重新登录。'; + } + + return error instanceof Error ? error.message : fallback; + }, + }, +}); diff --git a/frontend/src/stores/platform.ts b/frontend/src/stores/platform.ts new file mode 100644 index 0000000..e97510a --- /dev/null +++ b/frontend/src/stores/platform.ts @@ -0,0 +1,249 @@ +import { defineStore } from 'pinia'; + +import { + activatePlatformConnection, + createPlatformConnection, + deletePlatformConnection, + getPlatformChallenge, + getPlatformConnections, + getPlatformCourseQuerySchema, + getPlatformLoginSchema, + getPlatforms, + reloginPlatformConnection, +} from '../lib/api'; +import { readText, storageKeys, writeText } from '../lib/storage'; +import type { + ChallengeSessionDto, + PlatformConnectionDto, + PlatformLoginStartResponse, + PlatformSchemaDto, + PlatformSummaryDto, +} from '../types/api'; +import { useAuthStore } from './auth'; + +interface PlatformState { + userId: number | null; + platforms: PlatformSummaryDto[]; + connections: PlatformConnectionDto[]; + selectedPlatformId: number | null; + loginSchemas: Record; + courseQuerySchemas: Record; + isLoading: boolean; + error: string; + message: string; + currentChallenge: ChallengeSessionDto | null; + isPollingChallenge: boolean; +} + +let challengePollTimer: ReturnType | null = null; + +export const usePlatformStore = defineStore('platform', { + state: (): PlatformState => ({ + userId: null, + platforms: [], + connections: [], + selectedPlatformId: null, + loginSchemas: {}, + courseQuerySchemas: {}, + isLoading: false, + error: '', + message: '', + currentChallenge: null, + isPollingChallenge: false, + }), + getters: { + activeConnection(state): PlatformConnectionDto | null { + return state.connections.find((item) => item.isActive) ?? null; + }, + selectedPlatform(state): PlatformSummaryDto | null { + return state.platforms.find((item) => item.id === state.selectedPlatformId) ?? null; + }, + activeConnectionLabel(): string { + if (!this.activeConnection) { + return '未连接平台'; + } + + return this.activeConnection.status === 'connected' + ? `${this.activeConnection.platformName} 已连接` + : `${this.activeConnection.platformName} 连接中`; + }, + }, + actions: { + restoreForUser(userId: number): void { + this.userId = userId; + this.platforms = []; + this.connections = []; + this.loginSchemas = {}; + this.courseQuerySchemas = {}; + this.error = ''; + this.message = ''; + this.currentChallenge = null; + this.isPollingChallenge = false; + + const savedPlatformId = readText(storageKeys.selectedPlatformId(userId)); + this.selectedPlatformId = savedPlatformId ? Number(savedPlatformId) : null; + void this.bootstrapPlatformContext(); + }, + resetInMemory(): void { + this.userId = null; + this.platforms = []; + this.connections = []; + this.selectedPlatformId = null; + this.loginSchemas = {}; + this.courseQuerySchemas = {}; + this.isLoading = false; + this.error = ''; + this.message = ''; + this.currentChallenge = null; + this.isPollingChallenge = false; + if (challengePollTimer) { + window.clearTimeout(challengePollTimer); + challengePollTimer = null; + } + }, + async bootstrapPlatformContext(): Promise { + if (this.isLoading) return; // prevent concurrent calls + this.isLoading = true; + this.error = ''; + + try { + await Promise.all([this.loadPlatforms(), this.loadConnections()]); + + if (!this.selectedPlatformId) { + this.selectedPlatformId = this.activeConnection?.platformId ?? this.platforms[0]?.id ?? null; + } + + if (this.userId && this.selectedPlatformId) { + writeText(storageKeys.selectedPlatformId(this.userId), String(this.selectedPlatformId)); + } + + if (this.selectedPlatformId) { + await Promise.all([ + this.ensureLoginSchema(this.selectedPlatformId), + this.ensureCourseQuerySchema(this.selectedPlatformId), + ]); + } + } catch (error) { + this.error = this.resolveApiError(error, '平台数据加载失败。'); + } finally { + this.isLoading = false; + } + }, + async loadPlatforms(): Promise { + this.platforms = await getPlatforms(); + }, + async loadConnections(): Promise { + this.connections = await getPlatformConnections(); + }, + selectPlatform(platformId: number): void { + this.selectedPlatformId = platformId; + if (this.userId) { + writeText(storageKeys.selectedPlatformId(this.userId), String(platformId)); + } + + // Auto-activate a connected connection for this platform if one exists + const conn = this.connections.find(c => c.platformId === platformId && c.status === 'connected'); + if (conn && !conn.isActive) { + void this.activateConnection(conn.id); + } + + void Promise.all([this.ensureLoginSchema(platformId), this.ensureCourseQuerySchema(platformId)]); + }, + async ensureLoginSchema(platformId: number): Promise { + const key = String(platformId); + if (!this.loginSchemas[key]) { + this.loginSchemas[key] = await getPlatformLoginSchema(platformId); + } + + return this.loginSchemas[key]; + }, + async ensureCourseQuerySchema(platformId: number): Promise { + const key = String(platformId); + if (!this.courseQuerySchemas[key]) { + this.courseQuerySchemas[key] = await getPlatformCourseQuerySchema(platformId); + } + + return this.courseQuerySchemas[key]; + }, + async createConnection( + platformId: number, + connectionName: string, + fields: Record, + ): Promise { + this.error = ''; + const response = await createPlatformConnection({ + platformId, + connectionName: connectionName.trim() || null, + fields, + }); + + await this.loadConnections(); + this.message = response.message; + if (response.connection?.platformId) { + this.selectPlatform(response.connection.platformId); + } + + if (response.status === 'challenge_required' && response.challengeSessionId) { + await this.startChallengePolling(response.challengeSessionId); + } + + return response; + }, + async reloginConnection( + connectionId: number, + fields: Record, + ): Promise { + this.error = ''; + const response = await reloginPlatformConnection(connectionId, { fields }); + await this.loadConnections(); + this.message = response.message; + + if (response.status === 'challenge_required' && response.challengeSessionId) { + await this.startChallengePolling(response.challengeSessionId); + } + + return response; + }, + async activateConnection(connectionId: number): Promise { + await activatePlatformConnection(connectionId); + await this.loadConnections(); + }, + async deleteConnection(connectionId: number): Promise { + await deletePlatformConnection(connectionId); + await this.loadConnections(); + }, + async startChallengePolling(challengeSessionId: string): Promise { + if (challengePollTimer) { + window.clearTimeout(challengePollTimer); + } + + this.isPollingChallenge = true; + const poll = async () => { + try { + const challenge = await getPlatformChallenge(challengeSessionId); + this.currentChallenge = challenge; + if (challenge.status === 'pending') { + challengePollTimer = window.setTimeout(() => { + void poll(); + }, 2000); + return; + } + + this.isPollingChallenge = false; + this.message = challenge.message; + await this.loadConnections(); + challengePollTimer = null; + } catch (error) { + this.isPollingChallenge = false; + this.error = this.resolveApiError(error, '验证状态查询失败。'); + challengePollTimer = null; + } + }; + + await poll(); + }, + resolveApiError(error: unknown, fallback: string): string { + return useAuthStore().handleApiError(error) || fallback; + }, + }, +}); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..2b392d7 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,513 @@ +export interface ProblemDetails { + title?: string; + detail?: string; + status?: number; +} + +export interface PublicAuthConfigResponse { + registrationMode: 'open' | 'invite_only'; + systemName: string; + requireEmailVerification: boolean; +} + +export interface RegisterRequest { + username: string; + displayName: string; + password: string; + inviteCode?: string; + email?: string; + emailCode?: string; +} + +export interface SendEmailCodeRequest { + email: string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +export interface AuthUserDto { + id: number; + username: string; + displayName: string; + role: 'user' | 'admin'; + status: 'active' | 'disabled'; + createdAt: string; + lastLoginAt: string | null; +} + +export interface AuthTokenResponse { + accessToken: string; + expiresAt: string; + user: AuthUserDto; +} + +export interface UpdateUserRequest { + displayName?: string; + role?: 'user' | 'admin'; + status?: 'active' | 'disabled'; +} + +export interface InviteCodeDto { + id: number; + code: string; + status: 'active' | 'disabled'; + maxUses: number; + usedCount: number; + expiresAt: string | null; + createdAt: string; + createdByDisplayName: string; +} + +export interface CreateInviteCodeRequest { + code?: string; + maxUses: number; + expiresAt?: string; +} + +export interface UpdateInviteCodeRequest { + status: 'active' | 'disabled'; +} + +export interface SystemSettingDto { + systemName: string; + registrationMode: 'open' | 'invite_only'; + allowMockFallback: boolean; + browserChallengeTimeoutSeconds: number; + connectionEncryptionVersion: number; + defaultPlatformVisibility: string; + requireEmailVerification: boolean; + smtpHost: string | null; + smtpPort: number; + smtpUseSsl: boolean; + smtpUsername: string | null; + hasSmtpPassword: boolean; + smtpFromEmail: string | null; + updatedAt: string; +} + +export interface UpdateSystemSettingRequest { + systemName: string; + registrationMode: 'open' | 'invite_only'; + allowMockFallback: boolean; + browserChallengeTimeoutSeconds: number; + connectionEncryptionVersion: number; + defaultPlatformVisibility: string; + requireEmailVerification: boolean; + smtpHost: string | null; + smtpPort: number; + smtpUseSsl: boolean; + smtpUsername: string | null; + smtpPassword: string | null; + smtpFromEmail: string | null; +} + +export interface SelectOptionDto { + label: string; + value: string; +} + +export interface PlatformFieldDefinitionDto { + id: number; + scope: 'login' | 'course_query'; + key: string; + label: string; + type: 'text' | 'password' | 'number' | 'select' | 'textarea' | 'captcha_text' | 'sms_code' | 'email_code' | 'hidden'; + isRequired: boolean; + displayOrder: number; + placeholder: string | null; + helpText: string | null; + defaultValue: string | null; + isSensitive: boolean; + options: SelectOptionDto[]; +} + +export interface PlatformCookieMappingDto { + name: string; + expression: string; +} + +export interface PlatformOutputVariableDto { + key: string; + expression: string; +} + +export interface CourseOptionMappingDto { + itemsPath: string; + labelPath: string; + valuePath: string; +} + +export interface CatalogMappingDto { + chaptersPath: string; + chapterIdPath: string; + chapterNumberPath: string; + chapterNamePath: string; + chapterFinishedPath: string; + chapterLearningPath: string; + sectionsPath: string; + sectionIdPath: string; + sectionNumberPath: string; + sectionNamePath: string; + sectionFinishedPath: string; + sectionLearningPath: string; + sectionTaskIdPath: string; +} + +export interface UnitMappingDto { + itemsPath: string; + itemIdPath: string; + itemTitlePath: string; + itemTypePath: string; + itemFinishedPath: string; + videoSourcePath: string; + videoSourceNamePath: string; + videoPositionPath: string; + videoLengthPath: string; + documentCountPath: string; +} + +export interface PlatformWorkflowStepDto { + id: number; + scope: 'login' | 'course_query' | 'catalog' | 'units' | 'progress'; + stepKey: string; + displayName: string; + displayOrder: number; + stepType: 'http_request' | 'session_passthrough' | 'browser_challenge'; + httpMethod: string; + urlTemplate: string | null; + queryTemplateJson: string | null; + headersTemplateJson: string | null; + bodyTemplateJson: string | null; + contentType: string | null; + successPath: string | null; + successExpectedValue: string | null; + platformUserLabelExpression: string | null; + outputCookies: PlatformCookieMappingDto[]; + outputVariables: PlatformOutputVariableDto[]; + courseOptionMapping: CourseOptionMappingDto | null; + catalogMapping: CatalogMappingDto | null; + unitMapping: UnitMappingDto | null; + browserSuccessUrlContains: string | null; + browserSuccessCookieName: string | null; + browserWaitForSelector: string | null; + browserTimeoutSeconds: number | null; + browserAutomationJson: string | null; + isEnabled: boolean; +} + +export interface PlatformSummaryDto { + id: number; + slug: string; + displayName: string; + description: string; + status: 'draft' | 'active' | 'disabled'; + enableBrowserChallenge: boolean; +} + +export interface PlatformDefinitionDto { + id: number; + slug: string; + displayName: string; + description: string; + status: 'draft' | 'active' | 'disabled'; + enableBrowserChallenge: boolean; + courseQueryStepKey: string | null; + supportsCatalog: boolean; + supportsUnits: boolean; + supportsProgress: boolean; + challengeTimeoutSeconds: number; + loginFields: PlatformFieldDefinitionDto[]; + courseQueryFields: PlatformFieldDefinitionDto[]; + loginSteps: PlatformWorkflowStepDto[]; + courseQuerySteps: PlatformWorkflowStepDto[]; + catalogSteps: PlatformWorkflowStepDto[]; + unitSteps: PlatformWorkflowStepDto[]; + progressSteps: PlatformWorkflowStepDto[]; +} + +export interface PlatformSchemaDto { + platformId: number; + platformName: string; + scope: 'login' | 'course_query'; + fields: PlatformFieldDefinitionDto[]; +} + +export interface UpsertPlatformFieldDefinitionRequest { + id?: number | null; + scope: 'login' | 'course_query'; + key: string; + label: string; + type: PlatformFieldDefinitionDto['type']; + isRequired: boolean; + displayOrder: number; + placeholder?: string | null; + helpText?: string | null; + defaultValue?: string | null; + isSensitive: boolean; + options: SelectOptionDto[]; +} + +export interface UpsertPlatformWorkflowStepRequest { + id?: number | null; + scope: PlatformWorkflowStepDto['scope']; + stepKey: string; + displayName: string; + displayOrder: number; + stepType: PlatformWorkflowStepDto['stepType']; + httpMethod: string; + urlTemplate?: string | null; + queryTemplateJson?: string | null; + headersTemplateJson?: string | null; + bodyTemplateJson?: string | null; + contentType?: string | null; + successPath?: string | null; + successExpectedValue?: string | null; + platformUserLabelExpression?: string | null; + outputCookies: PlatformCookieMappingDto[]; + outputVariables: PlatformOutputVariableDto[]; + courseOptionMapping?: CourseOptionMappingDto | null; + catalogMapping?: CatalogMappingDto | null; + unitMapping?: UnitMappingDto | null; + browserSuccessUrlContains?: string | null; + browserSuccessCookieName?: string | null; + browserWaitForSelector?: string | null; + browserTimeoutSeconds?: number | null; + browserAutomationJson?: string | null; + isEnabled: boolean; +} + +export interface SavePlatformDefinitionRequest { + slug: string; + displayName: string; + description: string; + status: 'draft' | 'active' | 'disabled'; + enableBrowserChallenge: boolean; + courseQueryStepKey?: string | null; + supportsCatalog: boolean; + supportsUnits: boolean; + supportsProgress: boolean; + challengeTimeoutSeconds: number; + fields: UpsertPlatformFieldDefinitionRequest[]; + steps: UpsertPlatformWorkflowStepRequest[]; +} + +export interface PlatformStatusPatchRequest { + status: 'draft' | 'active' | 'disabled'; +} + +export interface PlatformConnectionDto { + id: number; + platformId: number; + platformName: string; + platformSlug: string; + connectionName: string; + platformUserLabel: string | null; + status: 'pending' | 'connected' | 'challenge_pending' | 'failed' | 'disabled'; + isActive: boolean; + hasStoredCredentials: boolean; + hasChallengePending: boolean; + createdAt: string; + updatedAt: string; + lastValidatedAt: string | null; + lastSuccessfulLoginAt: string | null; + lastError: string | null; +} + +export interface PlatformLoginStartRequest { + platformId: number; + connectionName?: string | null; + fields: Record; +} + +export interface PlatformReloginRequest { + fields: Record; +} + +export interface UoocLoginRequest { + platformId: number; + connectionName?: string | null; + account: string; + password: string; + captchaVerifyParam: string; +} + +export interface UoocLoginResponse { + status: 'connected'; + message: string; + connection: PlatformConnectionDto | null; +} + +export interface ZhihuishuLoginRequest { + platformId: number; + connectionName?: string | null; + account: string; + password: string; + captchaValidate: string; +} + +export interface ZhihuishuLoginResponse { + status: 'connected'; + message: string; + connection: PlatformConnectionDto | null; +} + +export interface PlatformLoginStartResponse { + status: 'connected' | 'challenge_required'; + message: string; + connection: PlatformConnectionDto | null; + challengeSessionId: string | null; + challengeUrl: string | null; +} + +export interface ChallengeSessionDto { + id: string; + status: 'pending' | 'completed' | 'failed' | 'expired'; + message: string; + challengeUrl: string | null; + createdAt: string; + expiresAt: string; + completedAt: string | null; +} + +export interface CourseOptionDto { + value: string; + label: string; +} + +export interface PlatformCourseQueryRequest { + fields: Record; +} + +export interface CourseOptionsResponse { + connectionId: number; + platformName: string; + items: CourseOptionDto[]; + queriedAt: string; + message: string | null; +} + +export interface CatalogSectionDto { + id: string; + number: string; + name: string; + finished: boolean; + learning: boolean; + taskId: string; +} + +export interface CatalogChapterDto { + id: string; + number: string; + name: string; + finished: boolean; + learning: boolean; + sections: CatalogSectionDto[]; +} + +export interface CatalogResponse { + courseId: string; + chapters: CatalogChapterDto[]; + mock: boolean; + source: string; + message: string | null; +} + +export interface UnitsResponse { + courseId: string; + chapterId: string; + sectionId: string; + items: UnitItemDto[]; + mock: boolean; + source: string; + message: string | null; +} + +export interface BrushStatusDto { + taskId: string; + platformSlug: string; + courseId: string; + chaptersSummary: string; + status: string; + totalVideos: number; + completedVideos: number; + currentChapterName: string; + currentSectionName: string; + currentVideoTitle: string; + currentVideoPos: number; + currentVideoLength: number; + lastError: string | null; + retryCount: number; + queuedCount: number; + createdAt: string; + durationSeconds: number; +} + +export interface VideoSourceDto { + source: string; + sourceName: string; +} + +export interface UnitItemDto { + id: string; + title: string; + type: string; + finished: boolean; + hasVideo: boolean; + videoPosition: number; + videoLength: number | null; + primarySourceName: string | null; + primarySourceUrl: string | null; + documentCount: number; + videoSources: VideoSourceDto[]; +} + +export interface ProgressSummaryDto { + totalSections: number; + completedSections: number; + inProgressSections: number; + totalResources: number; + completedResources: number; + sectionCompletionRate: number; + resourceCompletionRate: number; +} + +export interface SectionProgressDto { + id: string; + number: string; + name: string; + finished: boolean; + learning: boolean; + state: 'completed' | 'in-progress' | 'not-started' | 'no-resource'; + resourceCount: number; + completedResourceCount: number; + resources: UnitItemDto[]; +} + +export interface ChapterProgressDto { + id: string; + number: string; + name: string; + finished: boolean; + completedSections: number; + totalSections: number; + sections: SectionProgressDto[]; +} + +export interface CourseProgressResponse { + courseId: string; + courseName: string; + summary: ProgressSummaryDto; + chapters: ChapterProgressDto[]; + refreshedAt: string; + mock: boolean; + source: string; + message: string | null; +} diff --git a/frontend/src/views/ChangePasswordView.vue b/frontend/src/views/ChangePasswordView.vue new file mode 100644 index 0000000..8cdd523 --- /dev/null +++ b/frontend/src/views/ChangePasswordView.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/views/CourseSelectionView.vue b/frontend/src/views/CourseSelectionView.vue new file mode 100644 index 0000000..96f931b --- /dev/null +++ b/frontend/src/views/CourseSelectionView.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/frontend/src/views/ForbiddenView.vue b/frontend/src/views/ForbiddenView.vue new file mode 100644 index 0000000..a10fd20 --- /dev/null +++ b/frontend/src/views/ForbiddenView.vue @@ -0,0 +1,13 @@ + diff --git a/frontend/src/views/PlatformConnectionsView.vue b/frontend/src/views/PlatformConnectionsView.vue new file mode 100644 index 0000000..c2af9e0 --- /dev/null +++ b/frontend/src/views/PlatformConnectionsView.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue new file mode 100644 index 0000000..09283f3 --- /dev/null +++ b/frontend/src/views/ProfileView.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/views/ProgressView.vue b/frontend/src/views/ProgressView.vue new file mode 100644 index 0000000..0592803 --- /dev/null +++ b/frontend/src/views/ProgressView.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend/src/views/admin/AdminInvitesView.vue b/frontend/src/views/admin/AdminInvitesView.vue new file mode 100644 index 0000000..835acce --- /dev/null +++ b/frontend/src/views/admin/AdminInvitesView.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/views/admin/AdminNodesView.vue b/frontend/src/views/admin/AdminNodesView.vue new file mode 100644 index 0000000..3986e8e --- /dev/null +++ b/frontend/src/views/admin/AdminNodesView.vue @@ -0,0 +1,102 @@ + + + diff --git a/frontend/src/views/admin/AdminPlatformsView.vue b/frontend/src/views/admin/AdminPlatformsView.vue new file mode 100644 index 0000000..2680e88 --- /dev/null +++ b/frontend/src/views/admin/AdminPlatformsView.vue @@ -0,0 +1,309 @@ + + + diff --git a/frontend/src/views/admin/AdminSettingsView.vue b/frontend/src/views/admin/AdminSettingsView.vue new file mode 100644 index 0000000..49d7664 --- /dev/null +++ b/frontend/src/views/admin/AdminSettingsView.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/views/admin/AdminTasksView.vue b/frontend/src/views/admin/AdminTasksView.vue new file mode 100644 index 0000000..e921dcb --- /dev/null +++ b/frontend/src/views/admin/AdminTasksView.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue new file mode 100644 index 0000000..4d19da9 --- /dev/null +++ b/frontend/src/views/admin/AdminUsersView.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue new file mode 100644 index 0000000..9ee42c0 --- /dev/null +++ b/frontend/src/views/auth/LoginView.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue new file mode 100644 index 0000000..c11cf86 --- /dev/null +++ b/frontend/src/views/auth/RegisterView.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..bb2af2d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..16dfedc --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..597cade --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5088', + changeOrigin: true, + }, + }, + }, + preview: { + port: 4173, + }, +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..bf069b8 --- /dev/null +++ b/index.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/zhihuishu_api_final.md b/zhihuishu_api_final.md new file mode 100644 index 0000000..72b0c75 --- /dev/null +++ b/zhihuishu_api_final.md @@ -0,0 +1,306 @@ +# 智慧树 (zhihuishu.com) API 逆向分析 + +> 日期: 2026-06-23 | 全部接口已验证通过 + +--- + +## 一、概述 + +智慧树有两套 API 体系: + +| 体系 | 域名 | 加密 | 课程类型 | +|------|------|------|---------| +| Zhidao | `studyservice-api` / `onlineservice-api` | AES-CBC (secretStr) | 共享学分课 | +| Hike | `hikeservice` / `studyresources` / `hike-teaching` | 无加密 | 校内学分课 | + +--- + +## 二、登录流程 + +### 4.1 密码登录(需要滑块验证码) + +``` +Step 1: GET passport.zhihuishu.com/login → 获取 Session Cookie (JSESSIONID等) + +Step 2: POST passport.zhihuishu.com/user/validateAccountAndPassword + Body: secretStr=Base64(encodeURIComponent(JSON.stringify({ + account: "手机号", + password: "密码", + validate: "滑块验证码token" + }))) + → {status: 1, uuid: "...", pwd: "一次性密码"} + +Step 3: POST appcomm-user.zhihuishu.com/.../checkNeedAuth + Body: uuid=xxx + → {rt: {needAuth: 0}} + +Step 4: GET passport.zhihuishu.com/login?pwd={pwd}&service={service} + → 302重定向链 → 设置 CASLOGC Cookie + 注意: 必须用 requests 库跟随重定向,urllib 不会自动跨域传 Cookie + +加密方式: btoa(encodeURIComponent(JSON)) — 就是 Base64 + URL编码 +(不是 AES!这是登录专用加密) +``` + +### 4.2 滑块验证码 + +网易易盾,captchaId: `75f9f716460a422f89a628f50fd8cc2b` + +```html + + +``` + +--- + +## 三、Zhidao API(共享学分课) + +### 5.1 AES 加密 + +```python +KEY_MAP = { + "home": b"7q9oko0vqb3la20r", # 课程列表 + "video": b"azp53h0kft7qi78q", # 视频/学习/进度 + "qa": b"kcGOlISPkYKRksSK", # 弹题 +} +IV = b"1g3qqdh4jvbskb9x" + +def aes_encrypt(data_str, key=KEY_MAP["video"]): + pad_len = 16 - len(data_str) % 16 + padded = data_str + chr(pad_len) * pad_len + cipher = AES.new(key, AES.MODE_CBC, IV) + return base64.b64encode(cipher.encrypt(padded.encode())).decode() +``` + +**请求格式:** +``` +POST /api/endpoint +Content-Type: application/x-www-form-urlencoded +Body: secretStr={AES密文}&dateFormate={毫秒时间戳} +``` + +⚠️ `dateFormate` 必须同时在**加密 JSON 内**和**表单字段外**,且值相同。 + +### 5.2 课程列表 + +``` +POST onlineservice-api.zhihuishu.com/gateway/t/v1/student/course/share/queryShareCourseInfo +加密密钥: HOME_KEY +加密前: {"status":0,"pageNo":1,"pageSize":10,"dateFormate":时间戳} + +返回: +{ + "code": 0, + "result": { + "totalCount": 1, + "courseOpenDtos": [{ + "secret": "RAC_id", // 后续 API 的课程标识 + "courseName": "课程名", + "recruitId": 389213, // 招生ID + "courseId": 1000076607, // 课程ID + "schoolName": "学校", + "teacherName": "教师" + }] + } +} +``` + +### 5.3 章节/视频列表 + +``` +先调 gologin: + GET studyservice-api.zhihuishu.com/login/gologin?fromurl=... + +再调: + POST studyservice-api.zhihuishu.com/gateway/t/v1/learning/videolist + 加密密钥: VIDEO_KEY + 加密前: {"recruitAndCourseId":"RAC_id","dateFormate":时间戳} + +返回: +{ + "code": 0, + "data": { + "courseId": 1000076607, + "videoChapterDtos": [{ + "id": 1001076678, // chapterId + "name": "章节名", + "videoLessons": [{ + "id": 1001297571, // lessonId + "name": "小节名", + "videoSmallLessons": [{ + "id": 4001, // smallLessonId (单视频时=0) + "videoId": 63921147,// 视频ID + "videoSec": 600, // 总时长(秒) + "chapterId": 1001076678 + }] + }] + }] + } +} +``` + +### 5.4 视频弹题信息 + +``` +POST studyservice-api.zhihuishu.com/gateway/t/v1/popupAnswer/loadVideoPointerInfo +加密密钥: VIDEO_KEY +加密前: {"lessonId":...,"lessonVideoId":...,"recruitId":...,"courseId":...,"dateFormate":时间戳} + +返回: questionPoint 数组 (弹题时间点 + 题目ID) +``` + +### 5.5 视频播放 URL + +``` +GET newbase.zhihuishu.com/video/initVideo?jsonpCallBack=result&videoID={videoId} +返回: JSONP,包含 lines[0].lineUrl +``` + +### 5.6 学习状态 + +``` +POST studyservice-api.zhihuishu.com/gateway/t/v1/learning/queryStuyInfo +加密密钥: VIDEO_KEY +加密前: +{ + "lessonIds": [1001297571], // 课时ID列表 + "lessonVideoIds": [], // 子视频ID列表,单视频课时(smallLessonId=0)填空数组[] + "recruitId": 389213, // 招生ID + "dateFormate": 1782200000000 +} +⚠️ lessonVideoIds 为 0 时不要传 [0],传空数组 [] + +返回: +{ + "code": 0, + "data": { + "lv": { + "4001": { "watchState": 1, "studyTotalTime": 600 } + }, + "lesson": { + "1001297571": { "watchState": 1, "studyTotalTime": 600 } + } + } +} +watchState: 0=未看完, 1=已看完 +studyTotalTime: 已学习秒数 +``` + +### 5.7 提交学习进度 + +``` +Step 1 (prelearningNote): + POST studyservice-api.zhihuishu.com/gateway/t/v1/learning/prelearningNote + 加密密钥: VIDEO_KEY + 加密前: {"ccCourseId":...,"chapterId":...,"isApply":1,"lessonId":..., + "lessonVideoId":...,"recruitId":...,"videoId":...,"dateFormate":时间戳} + 返回: data.studiedLessonDto.id → Base64编码得到 learningTokenId + +Step 2 (saveDatabaseIntervalTimeV2): + POST studyservice-api.zhihuishu.com/gateway/t/v1/learning/saveDatabaseIntervalTimeV2 + 加密密钥: VIDEO_KEY + 加密前: { + "ewssw": "0,1,2", // watchPoint + "sdsew": getEv([...]), // EV混淆的参数 + "zwsds": learningTokenId, // Base64编码的token + "courseId": ..., + "dateFormate": 时间戳 + } +``` + +### 5.8 EV 混淆算法 + +```python +def getEv(data_list, key="zzpttjd"): + """XOR 混淆""" + data = ';'.join(map(str, data_list)) + key_cycle = (ord(c) for _ in iter(int,1) for c in key) + ev = '' + for c in data: + tmp = hex(ord(c) ^ next(key_cycle)).replace('0x', '') + if len(tmp) < 2: tmp = '0' + tmp + ev += tmp[-4:] + return ev + +# saveDatabaseIntervalTimeV2 的 raw_ev 参数: +raw_ev = [ + recruitId, lessonId, smallLessonId, videoId, chapterId, + '0', # studyStatus + played_time - last_submit, # 本次播放时长 + played_time, # 累计播放时长 + HMS(played_time), # HH:MM:SS 格式 + uuid + "zhs" # UUID后缀 +] +``` + +--- + +## 四、Hike API(校内学分课) + +无需 AES 加密,直接 GET 请求,带 Cookie 即可。 + +### 6.1 课程列表 + +``` +GET hikeservice.zhihuishu.com/student/course/aided/getMyCourseList?uuid={uuid}&data={UTC时间} +返回: result.startInngcourseList (注意拼写) +``` + +### 6.2 章节/资源树 + +``` +GET studyresources.zhihuishu.com/studyResources/stuResouce/queryResourceMenuTree?courseId={id} +返回: rt 数组,childList 非空=目录,childList=null=文件,dataType=3=视频 +``` + +### 6.3 视频信息 + +``` +GET studyresources.zhihuishu.com/studyResources/stuResouce/stuViewFile?courseId={id}&fileId={id} +返回: dataId (视频流ID), totalTime, studyTime +``` + +### 6.4 提交学习记录 + +``` +GET hike-teaching.zhihuishu.com/stuStudy/saveStuStudyRecord?uuid=...&courseId=...&fileId=... + &studyTotalTime=...&startWatchTime=...&endWatchTime=...&startDate=...&endDate=... + &signature=MD5(SALT + uuid + courseId + fileId + studyTotalTime + startDate + endDate + endWatchTime + startWatchTime + uuid) + +SALT = "o6xpt3b#Qy$Z" +``` + +--- + +## 五、Cookie 体系 + +登录后获取的关键 Cookie: + +| Cookie | 域名 | 说明 | +|--------|------|------| +| CASLOGC | passport.zhihuishu.com | URL编码JSON,含uuid/realName/userId | +| JSESSIONID | passport.zhihuishu.com | Session ID | +| SERVERID | 各子域名 | 服务器路由 | +| SESSION | onlineservice-api / studyservice-api | API会话 (通过CAS重定向获取) | + +⚠️ 登录时必须跟随完整 CAS 重定向链,否则缺少 `onlineservice-api` 的 SESSION Cookie 导致 API 返回 401。 + +--- + +## 六、关键注意事项 + +1. **AES JSON 格式**: `json.dumps(data, separators=(',', ':'))` — 紧凑格式,无空格 +2. **dateFormate 双写**: 加密 JSON 内和表单字段都要有,且值相同 +3. **lessonVideoIds 不能含 0**: 单视频课时 smallLessonId=0,此时传空数组 `[]` +4. **CAS 重定向**: 必须用 requests.Session() 自动跟随,urllib 不会跨域传 Cookie +5. **latin-1 编码**: Cookie 值保持 URL 编码状态(纯 ASCII),不要解码成中文 +6. **Encrypt 已变更**: 登录接口用 `btoa(encodeURIComponent(JSON))`,不是 AES