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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:31:32 +08:00
co-authored by Claude Fable 5
commit cd486a36ef
84 changed files with 16242 additions and 0 deletions
+487
View File
@@ -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/
+133
View File
@@ -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` 体验演示数据。
+42
View File
@@ -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
@@ -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<IReadOnlyList<AdminBrushTaskDto>> 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<BrushSystemConfig> GetConfig() => Ok(brushService.Config);
[HttpPut("config")]
public ActionResult<BrushSystemConfig> UpdateConfig([FromBody] BrushSystemConfig config)
{
brushService.Config.PauseNewTasks = config.PauseNewTasks;
return Ok(brushService.Config);
}
}
@@ -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<IReadOnlyList<AuthUserDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AuthUserDto>>> 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<AuthUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthUserDto>> 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<IReadOnlyList<InviteCodeDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<InviteCodeDto>>> 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<InviteCodeDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<InviteCodeDto>> 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<InviteCodeDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<InviteCodeDto>> 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<SystemSettingDto>(StatusCodes.Status200OK)]
public async Task<ActionResult<SystemSettingDto>> GetSettings(CancellationToken cancellationToken) =>
Ok(await settingsService.GetDtoAsync(cancellationToken));
[HttpPut("api/admin/settings")]
[ProducesResponseType<SystemSettingDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SystemSettingDto>> 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<IReadOnlyList<PlatformSummaryDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<PlatformSummaryDto>>> GetPlatforms(CancellationToken cancellationToken) =>
Ok(await platformDefinitionService.GetAdminListAsync(cancellationToken));
[HttpPost("api/admin/platforms")]
[ProducesResponseType<PlatformDefinitionDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PlatformDefinitionDto>> 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<PlatformDefinitionDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlatformDefinitionDto>> 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<PlatformDefinitionDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PlatformDefinitionDto>> 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<PlatformDefinitionDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PlatformDefinitionDto>> 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<PlatformDefinitionDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PlatformDefinitionDto>> 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<string> 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
};
}
@@ -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<ActionResult<IReadOnlyList<AutomationNode>>> GetNodes() => Ok(await nodeService.GetNodesAsync());
[HttpPost("token")]
public ActionResult<TokenResponse> GenerateToken()
{
var token = NodeService.GenerateToken();
return Ok(new TokenResponse(token));
}
[HttpDelete("{nodeId:long}")]
public async Task<IActionResult> Delete(long nodeId) { await nodeService.DeleteNodeAsync(nodeId); return NoContent(); }
[HttpGet("tasks")]
public async Task<ActionResult<IReadOnlyList<NodeTask>>> GetTasks() => Ok(await nodeService.GetTasksAsync());
[HttpPost("tasks/{taskId:long}/cancel")]
public async Task<IActionResult> CancelTask(long taskId) { await nodeService.CancelTaskAsync(taskId); return NoContent(); }
public sealed record TokenResponse(string Token);
}
@@ -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<UserAccount> passwordHasher,
JwtTokenService jwtTokenService,
SystemSettingsService settingsService,
EmailVerificationService emailVerificationService) : ControllerBase
{
[HttpPost("send-email-code")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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<AuthTokenResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AuthTokenResponse>> 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<AuthTokenResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthTokenResponse>> 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<AuthUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthUserDto>> 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<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> 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<UserAccount?> 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
};
}
@@ -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<BrushStatusDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BrushStatusDto>> 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<List<BrushStatusDto>>(StatusCodes.Status200OK)]
public ActionResult<List<BrushStatusDto>> 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<BrushStatusDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public ActionResult<BrushStatusDto> Retry()
{
try
{
return Ok(brushService.Retry(GetUserId()));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ProblemDetails { Title = "重试失败", Detail = ex.Message, Status = 400 });
}
}
[HttpPost("{taskId}/retry")]
[ProducesResponseType<BrushStatusDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public ActionResult<BrushStatusDto> 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<ChapterBrushInput> Chapters);
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using UoocProgress.Api.Models;
using UoocProgress.Api.Services;
namespace UoocProgress.Api.Controllers;
/// <summary>
/// Endpoints called by automation nodes (token auth) — no JWT required.
/// </summary>
[ApiController]
[Route("api/node")]
public sealed class NodeController(NodeService nodeService) : ControllerBase
{
[HttpPost("register")]
public async Task<ActionResult<NodeRegisterResponse>> 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<IActionResult> Heartbeat([FromBody] NodeHeartbeatRequest req)
{
await nodeService.HeartbeatAsync(req.NodeId, HttpContext.Connection.RemoteIpAddress?.ToString());
return NoContent();
}
[HttpGet("poll")]
public async Task<ActionResult<NodeTaskResponse?>> 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<IActionResult> ReportProgress([FromBody] NodeProgressRequest req)
{
await nodeService.UpdateProgressAsync(req.TaskId, req.CompletedSteps, req.CurrentStep, req.LastError);
return NoContent();
}
[HttpPost("complete")]
public async Task<IActionResult> Complete([FromBody] NodeCompleteRequest req)
{
await nodeService.CompleteAsync(req.TaskId);
return NoContent();
}
[HttpPost("fail")]
public async Task<IActionResult> 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);
}
@@ -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<ChallengeSessionDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public ActionResult<ChallengeSessionDto> 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);
}
}
@@ -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<IReadOnlyList<PlatformConnectionDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<PlatformConnectionDto>>> GetConnections(CancellationToken cancellationToken) =>
Ok(await platformConnectionService.GetConnectionsAsync(GetCurrentUserId(), cancellationToken));
[HttpPost]
[ProducesResponseType<PlatformLoginStartResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status502BadGateway)]
public async Task<ActionResult<PlatformLoginStartResponse>> 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<PlatformLoginStartResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<PlatformLoginStartResponse>> 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<IActionResult> 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<IActionResult> 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<UoocLoginResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status502BadGateway)]
public async Task<ActionResult<UoocLoginResponse>> 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<ZhihuishuLoginResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status502BadGateway)]
public async Task<ActionResult<ZhihuishuLoginResponse>> 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<CourseOptionsResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<CourseOptionsResponse>> 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<CatalogResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogResponse>> 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<CourseProgressResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<CourseProgressResponse>> 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<UnitsResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<UnitsResponse>> 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
};
}
@@ -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<IReadOnlyList<PlatformSummaryDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<PlatformSummaryDto>>> GetPlatforms(CancellationToken cancellationToken)
{
var settings = await settingsService.GetEntityAsync(cancellationToken);
return Ok(await platformDefinitionService.GetActiveListAsync(settings.DefaultPlatformVisibility, cancellationToken));
}
[HttpGet("{platformId:long}/schemas/login")]
[ProducesResponseType<PlatformSchemaDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlatformSchemaDto>> GetLoginSchema(long platformId, CancellationToken cancellationToken) =>
await GetSchemaAsync(platformId, PlatformFieldScope.Login, "login", cancellationToken);
[HttpGet("{platformId:long}/schemas/course-query")]
[ProducesResponseType<PlatformSchemaDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlatformSchemaDto>> GetCourseQuerySchema(long platformId, CancellationToken cancellationToken) =>
await GetSchemaAsync(platformId, PlatformFieldScope.CourseQuery, "course_query", cancellationToken);
private async Task<ActionResult<PlatformSchemaDto>> 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));
}
}
@@ -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<PublicAuthConfigResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<PublicAuthConfigResponse>> GetAuthConfig(CancellationToken cancellationToken)
{
var settings = await settingsService.GetEntityAsync(cancellationToken);
return Ok(new PublicAuthConfigResponse(
EnumValueCodec.ToApiValue(settings.RegistrationMode),
settings.SystemName,
settings.RequireEmailVerification));
}
}
@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore;
using UoocProgress.Api.Models;
namespace UoocProgress.Api.Data;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<InviteCodeRecord> InviteCodes => Set<InviteCodeRecord>();
public DbSet<SystemSettingRecord> SystemSettings => Set<SystemSettingRecord>();
public DbSet<PlatformDefinition> PlatformDefinitions => Set<PlatformDefinition>();
public DbSet<PlatformFieldDefinition> PlatformFieldDefinitions => Set<PlatformFieldDefinition>();
public DbSet<PlatformWorkflowStep> PlatformWorkflowSteps => Set<PlatformWorkflowStep>();
public DbSet<UserPlatformConnection> UserPlatformConnections => Set<UserPlatformConnection>();
public DbSet<AutomationNode> AutomationNodes => Set<AutomationNode>();
public DbSet<NodeTask> NodeTasks => Set<NodeTask>();
public DbSet<BrushTaskRecord> BrushTasks => Set<BrushTaskRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(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<string>().HasMaxLength(16).IsRequired();
entity.Property(item => item.Status).HasConversion<string>().HasMaxLength(16).IsRequired();
entity.HasIndex(item => item.UsernameNormalized).IsUnique();
});
modelBuilder.Entity<InviteCodeRecord>(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<string>().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<SystemSettingRecord>(entity =>
{
entity.ToTable("system_settings");
entity.HasKey(item => item.Id);
entity.Property(item => item.SystemName).HasMaxLength(128).IsRequired();
entity.Property(item => item.RegistrationMode).HasConversion<string>().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<PlatformDefinition>(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<string>().HasMaxLength(16).IsRequired();
entity.Property(item => item.CourseQueryStepKey).HasMaxLength(64);
entity.HasIndex(item => item.Slug).IsUnique();
});
modelBuilder.Entity<PlatformFieldDefinition>(entity =>
{
entity.ToTable("platform_field_definitions");
entity.HasKey(item => item.Id);
entity.Property(item => item.Scope).HasConversion<string>().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<string>().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<PlatformWorkflowStep>(entity =>
{
entity.ToTable("platform_workflow_steps");
entity.HasKey(item => item.Id);
entity.Property(item => item.Scope).HasConversion<string>().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<string>().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<UserPlatformConnection>(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<string>().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<AutomationNode>(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<NodeTask>(entity =>
{
entity.ToTable("node_tasks");
entity.HasKey(item => item.Id);
entity.Property(item => item.Status).HasConversion<string>().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<BrushTaskRecord>(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 });
});
}
}
@@ -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<CourseOptionMappingDto>(step.CourseOptionMappingJson),
DeserializeObject<CatalogMappingDto>(step.CatalogMappingJson),
DeserializeObject<UnitMappingDto>(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<SelectOptionDto> DeserializeOptions(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<SelectOptionDto>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private static IReadOnlyList<PlatformCookieMappingDto> DeserializeCookieMappings(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<PlatformCookieMappingDto>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private static IReadOnlyList<PlatformOutputVariableDto> DeserializeVariableMappings(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<PlatformOutputVariableDto>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private static T? DeserializeObject<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch
{
return default;
}
}
}
@@ -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<SelectOptionDto> 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<PlatformCookieMappingDto> OutputCookies,
IReadOnlyList<PlatformOutputVariableDto> 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<PlatformFieldDefinitionDto> LoginFields,
IReadOnlyList<PlatformFieldDefinitionDto> CourseQueryFields,
IReadOnlyList<PlatformWorkflowStepDto> LoginSteps,
IReadOnlyList<PlatformWorkflowStepDto> CourseQuerySteps,
IReadOnlyList<PlatformWorkflowStepDto> CatalogSteps,
IReadOnlyList<PlatformWorkflowStepDto> UnitSteps,
IReadOnlyList<PlatformWorkflowStepDto> ProgressSteps);
public sealed record PlatformSchemaDto(
long PlatformId,
string PlatformName,
string Scope,
IReadOnlyList<PlatformFieldDefinitionDto> 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<SelectOptionDto> 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<PlatformCookieMappingDto> OutputCookies,
IReadOnlyList<PlatformOutputVariableDto> 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<UpsertPlatformFieldDefinitionRequest> Fields,
IReadOnlyList<UpsertPlatformWorkflowStepRequest> 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<string, string?> Fields);
public sealed record PlatformReloginRequest(
IReadOnlyDictionary<string, string?> 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<string, string?> Fields);
public sealed record CourseOptionsResponse(
long ConnectionId,
string PlatformName,
IReadOnlyList<CourseOptionDto> 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<CatalogSectionDto> Sections);
public sealed record CatalogResponse(
string CourseId,
IReadOnlyList<CatalogChapterDto> 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<VideoSourceDto> VideoSources,
string CatalogId);
public sealed record UnitsResponse(
string CourseId,
string ChapterId,
string SectionId,
IReadOnlyList<UnitItemDto> 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<UnitItemDto> Resources);
public sealed record ChapterProgressDto(
string Id,
string Number,
string Name,
bool Finished,
int CompletedSections,
int TotalSections,
IReadOnlyList<SectionProgressDto> Sections);
public sealed record CourseProgressResponse(
string CourseId,
string CourseName,
ProgressSummaryDto Summary,
IReadOnlyList<ChapterProgressDto> Chapters,
DateTimeOffset RefreshedAt,
bool Mock,
string Source,
string? Message);
@@ -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<InviteCodeRecord> CreatedInviteCodes { get; set; } = [];
public ICollection<UserPlatformConnection> 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<PlatformFieldDefinition> FieldDefinitions { get; set; } = [];
public ICollection<PlatformWorkflowStep> WorkflowSteps { get; set; } = [];
public ICollection<UserPlatformConnection> 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; }
/// <summary>
/// JSON array of browser automation actions for the agent to execute.
/// [{action:"navigate"|"click"|"wait_selector"|"wait_seconds"|"scroll"|"fill", ...}]
/// </summary>
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;
}
@@ -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;
}
}
@@ -0,0 +1,19 @@
namespace UoocProgress.Api.Models;
public sealed record GatewayResult<T>(
bool IsSuccess,
bool IsUnauthorized,
bool IsMock,
string Source,
T? Data,
string? Message)
{
public static GatewayResult<T> Success(T data, bool isMock, string source, string? message = null) =>
new(true, false, isMock, source, data, message);
public static GatewayResult<T> Failure(string message) =>
new(false, false, false, "none", default, message);
public static GatewayResult<T> Unauthorized(string message) =>
new(false, true, false, "none", default, message);
}
@@ -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!";
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,35 @@
namespace UoocProgress.Api.Options;
public sealed class ZhihuishuOptions
{
public const string SectionName = "Zhihuishu";
/// <summary>智慧树 passport 域名</summary>
public string PassportBaseUrl { get; init; } = "https://passport.zhihuishu.com";
/// <summary>智慧树 onlineservice-api 域名 (共享学分课 - 课程列表)</summary>
public string OnlineServiceBaseUrl { get; init; } = "https://onlineservice-api.zhihuishu.com";
/// <summary>智慧树 studyservice-api 域名 (共享学分课 - 学习/视频)</summary>
public string StudyServiceBaseUrl { get; init; } = "https://studyservice-api.zhihuishu.com";
/// <summary>智慧树 newbase 域名 (视频播放)</summary>
public string NewbaseUrl { get; init; } = "https://newbase.zhihuishu.com";
/// <summary>智慧树 appcomm-user 域名 (认证检查)</summary>
public string AppcommUserBaseUrl { get; init; } = "https://appcomm-user.zhihuishu.com";
/// <summary>Hike 校内学分课 - hikeservice</summary>
public string HikeServiceBaseUrl { get; init; } = "https://hikeservice.zhihuishu.com";
/// <summary>Hike 校内学分课 - studyresources</summary>
public string StudyResourcesBaseUrl { get; init; } = "https://studyresources.zhihuishu.com";
/// <summary>Hike 校内学分课 - hike-teaching (提交学习记录)</summary>
public string HikeTeachingBaseUrl { get; init; } = "https://hike-teaching.zhihuishu.com";
/// <summary>CAS login 完成后的 service 参数</summary>
public string CasServiceUrl { get; init; } = "https://onlineservice-api.zhihuishu.com/gateway/t/v1/student/course/share/queryShareCourseInfo";
public int TimeoutSeconds { get; init; } = 30;
}
+150
View File
@@ -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<UoocOptions>(builder.Configuration.GetSection(UoocOptions.SectionName));
builder.Services.Configure<ZhihuishuOptions>(builder.Configuration.GetSection(ZhihuishuOptions.SectionName));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<BootstrapAdminOptions>(builder.Configuration.GetSection(BootstrapAdminOptions.SectionName));
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is required.");
builder.Services.AddDbContext<AppDbContext>(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<IOptions<UoocOptions>>().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<PasswordHasher<UserAccount>>();
builder.Services.AddScoped<JwtTokenService>();
builder.Services.AddScoped<SystemSettingsService>();
builder.Services.AddScoped<EmailVerificationService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<PlatformDefinitionService>();
builder.Services.AddScoped<PlatformWorkflowExecutor>();
builder.Services.AddScoped<PlatformConnectionService>();
builder.Services.AddScoped<NodeService>();
builder.Services.AddScoped<SecretProtectionService>();
builder.Services.AddSingleton<TemplateResolver>();
builder.Services.AddSingleton<SimpleJsonPathService>();
builder.Services.AddSingleton<ChallengeSessionService>();
builder.Services.AddSingleton<BrowserChallengeService>();
builder.Services.AddSingleton<MockUoocData>();
builder.Services.AddSingleton<UoocApiService>();
builder.Services.AddSingleton<ZhihuishuApiService>();
builder.Services.AddSingleton<VideoBrushService>();
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? 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<DatabaseInitializer>();
await initializer.InitializeAsync();
}
// Restore persisted brush tasks that were running before shutdown
app.Services.GetRequiredService<VideoBrushService>().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();
@@ -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"
}
}
}
}
@@ -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<IDictionary<string, string>, 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<IBrowser> 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<BrowserContextCookiesResult> 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;
}
}
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using UoocProgress.Api.Models;
namespace UoocProgress.Api.Services;
public sealed class ChallengeSessionService
{
private readonly ConcurrentDictionary<string, ChallengeSessionState> _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);
}
}
@@ -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<UserAccount> passwordHasher,
IOptions<BootstrapAdminOptions> adminOptions,
IOptions<UoocOptions> 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();
/// <summary>
/// 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.
/// </summary>
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>(T value) =>
JsonSerializer.Serialize(value, JsonOptions);
}
@@ -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<string, CodeEntry> _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}");
}
}
}
@@ -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<JwtOptions> 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());
}
}
@@ -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<string, MockCourseDefinition> _courses;
public MockUoocData()
{
_courses = new Dictionary<string, MockCourseDefinition>(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<string, IReadOnlyList<UnitItemDto>>(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<string, IReadOnlyList<UnitItemDto>>(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<string, IReadOnlyList<UnitItemDto>>(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<CourseOptionDto> 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<CatalogChapterDto> Chapters,
IReadOnlyDictionary<string, IReadOnlyList<UnitItemDto>> UnitsBySectionId);
}
@@ -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<AutomationNode> 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<IReadOnlyList<AutomationNode>> 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<NodeTask> EnqueueAsync(long userId, string courseId, string courseName,
string platformUrl, string taskDataJson)
{
// Count total steps from JSON: chapters → sections
var chapters = JsonSerializer.Deserialize<List<ChapterTaskData>>(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<NodeTask?> 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<IReadOnlyList<NodeTask>> 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<SectionTaskData> Sections);
public sealed record SectionTaskData(
string SectionName,
List<string> Urls);
File diff suppressed because it is too large Load Diff
@@ -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<IReadOnlyList<PlatformSummaryDto>> GetAdminListAsync(CancellationToken cancellationToken) =>
await dbContext.PlatformDefinitions
.AsNoTracking()
.OrderBy(item => item.DisplayName)
.Select(item => item.ToSummaryDto())
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<PlatformSummaryDto>> 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<PlatformDefinition?> 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<PlatformDefinitionDto?> GetByIdAsync(long platformId, CancellationToken cancellationToken)
{
var entity = await FindEntityAsync(platformId, cancellationToken);
return entity?.ToDto();
}
public async Task<PlatformDefinitionDto> 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<PlatformDefinitionDto> 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<PlatformDefinitionDto> 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<PlatformDefinitionDto> 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();
}
@@ -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> uoocOptions,
TemplateResolver templateResolver,
SimpleJsonPathService jsonPathService)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public IReadOnlyList<PlatformWorkflowStep> 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<IReadOnlyList<CourseOptionDto>> 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<CourseOptionMappingDto>(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<CourseOptionDto>();
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<CatalogResponse> 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<CatalogMappingDto>(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<CatalogChapterDto>();
foreach (var chapterNode in jsonPathService.ResolveArray(responseJson, mapping.ChaptersPath))
{
var sections = new List<CatalogSectionDto>();
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<UnitsResponse> 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<UnitMappingDto>(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<UnitItemDto>();
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<StepExecutionResult> 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<StepExecutionResult> 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<string, string>(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<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var mapping in DeserializeCookieMappings(step.OutputCookiesJson))
{
var value = ResolveExpression(mapping.Expression, state, null, new Dictionary<string, string>(), new Dictionary<string, string>());
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<string, string>(), new Dictionary<string, string>());
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<string, string> ResolveStringMap(string? json, WorkflowExecutionState state)
{
if (string.IsNullOrWhiteSpace(json))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var node = JsonNode.Parse(json) as JsonObject
?? throw new PlatformOperationException("请求模板 JSON 格式无效。");
var resolved = new Dictionary<string, string>(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<string>(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<string>(out var stringValue))
{
return ResolveTemplate(stringValue, state);
}
return node.ToJsonString().Trim('"');
}
return node.ToJsonString();
}
private IReadOnlyDictionary<string, string> BuildContext(IReadOnlyDictionary<string, string> source)
{
var context = new Dictionary<string, string>(source, StringComparer.OrdinalIgnoreCase);
context.TryAdd("uoocBaseUrl", uoocOptions.Value.BaseUrl.TrimEnd('/'));
return context;
}
private string ResolveExpression(
string expression,
WorkflowExecutionState state,
JsonNode? responseJson,
IReadOnlyDictionary<string, string> responseCookies,
IReadOnlyDictionary<string, string> 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<string, string> ExtractCookies(HttpResponseMessage response)
{
var cookies = new Dictionary<string, string>(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<KeyValuePair<string, string>> ResolveFormValues(JsonNode bodyNode)
{
if (bodyNode is not JsonObject bodyObject)
{
return [];
}
return bodyObject
.Where(item => item.Value is not null)
.Select(item => new KeyValuePair<string, string>(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<PlatformCookieMappingDto> DeserializeCookieMappings(string? json) =>
DeserializeList<PlatformCookieMappingDto>(json);
private static IReadOnlyList<PlatformOutputVariableDto> DeserializeVariableMappings(string? json) =>
DeserializeList<PlatformOutputVariableDto>(json);
private static IReadOnlyList<T> DeserializeList<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private static T? Deserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch
{
return default;
}
}
private sealed record StepExecutionResult(JsonNode? ResponseJson);
}
@@ -0,0 +1,42 @@
using System.Text.Json.Nodes;
namespace UoocProgress.Api.Services;
public sealed class PlatformSessionData
{
public Dictionary<string, string> Cookies { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, Dictionary<string, string>> StepOutputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class WorkflowExecutionState
{
public Dictionary<string, string> InputFields { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public PlatformSessionData SessionData { get; init; } = new();
public Dictionary<string, string> ContextValues { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonNode?> StepJson { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, IReadOnlyDictionary<string, string>> 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; }
}
@@ -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<string, string> values) =>
_protector.Protect(JsonSerializer.Serialize(values, JsonOptions));
public Dictionary<string, string> UnprotectDictionary(string? protectedValue)
{
if (string.IsNullOrWhiteSpace(protectedValue))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(
_protector.Unprotect(protectedValue),
JsonOptions)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
catch
{
return new Dictionary<string, string>(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<PlatformSessionData>(
_protector.Unprotect(protectedValue),
JsonOptions)
?? new PlatformSessionData();
}
catch
{
return new PlatformSessionData();
}
}
}
@@ -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(
"^(?<name>[^\\[]+)(\\[(?<index>\\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<string>(out var stringValue))
{
return stringValue;
}
if (value.TryGetValue<bool>(out var boolValue))
{
return boolValue ? "true" : "false";
}
if (value.TryGetValue<int>(out var intValue))
{
return intValue.ToString(CultureInfo.InvariantCulture);
}
if (value.TryGetValue<long>(out var longValue))
{
return longValue.ToString(CultureInfo.InvariantCulture);
}
if (value.TryGetValue<double>(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 ?? [];
}
}
@@ -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<SystemSettingRecord> 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<SystemSettingDto> GetDtoAsync(CancellationToken cancellationToken = default) =>
(await GetEntityAsync(cancellationToken)).ToDto();
public async Task<bool> GetAllowMockFallbackAsync(CancellationToken cancellationToken = default) =>
(await GetEntityAsync(cancellationToken)).AllowMockFallback;
public async Task<SystemSettingDto> 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();
}
}
@@ -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*(?<token>[^}]+)\s*\}\}", RegexOptions.Compiled);
public string Resolve(
string? template,
IReadOnlyDictionary<string, string> fields,
PlatformSessionData sessionData,
IReadOnlyDictionary<string, string> context,
IReadOnlyDictionary<string, Dictionary<string, string>> 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<string, string> fields,
PlatformSessionData sessionData,
IReadOnlyDictionary<string, string> context,
IReadOnlyDictionary<string, Dictionary<string, string>> 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<string, string> fields,
PlatformSessionData sessionData,
IReadOnlyDictionary<string, string> context,
IReadOnlyDictionary<string, Dictionary<string, string>> 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;
}
}
@@ -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;
/// <summary>
/// Direct UOOC API calls with hardcoded JSON paths.
/// Bypasses the generic database-configured workflow for known UOOC endpoints.
/// </summary>
public sealed class UoocApiService(
IHttpClientFactory httpClientFactory,
IOptions<UoocOptions> uoocOptions)
{
public async Task<IReadOnlyList<CourseOptionDto>> 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<CourseOptionDto>();
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<CatalogResponse> 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<CatalogChapterDto>();
foreach (var chNode in ResolveArray(json, "$.data"))
{
var sections = new List<CatalogSectionDto>();
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<UnitsResponse> 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"
+ $"&section_id={Uri.EscapeDataString(sectionId)}"
+ $"&show=";
var response = await client.GetAsync(url, cancellationToken);
var json = await ReadJson(response, cancellationToken);
var items = new List<UnitItemDto>();
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<VideoSourceDto>();
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<JsonNode> 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<string>(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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
/// <summary>
/// Direct Zhihuishu (智慧树) API calls.
/// Handles CAS login, AES-encrypted Zhidao APIs, and Hike APIs.
/// </summary>
public sealed class ZhihuishuApiService(
IHttpClientFactory httpClientFactory,
IOptions<ZhihuishuOptions> 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; } = "";
}
/// <summary>
/// Full CAS login flow for Zhihuishu.
/// Steps: GET passport/login → POST validateAccountAndPassword → POST checkNeedAuth → GET CAS redirect chain.
/// </summary>
public async Task<ZhihuishuLoginResult> 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<string, string>
{
["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<string, string>
{
["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<IReadOnlyList<CourseOptionDto>> 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<string, string>
{
["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<CourseOptionDto>();
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<CatalogResponse> 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<string, string>
{
["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<CatalogChapterDto>();
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<CatalogSectionDto>();
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<object>();
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<string?> 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; } // 已学习秒数
}
/// <summary>Query study progress for a batch of lessons. Returns dict keyed by lessonId or smallLessonId.</summary>
public async Task<Dictionary<string, LessonProgress>> QueryLessonProgressAsync(
PlatformSessionData sessionData,
List<string> lessonIds,
List<string> lessonVideoIds,
string recruitId,
CancellationToken ct = default)
{
var result = new Dictionary<string, LessonProgress>();
if (lessonIds.Count == 0) return result;
var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var payload = JsonSerializer.Serialize(new
{
lessonIds,
lessonVideoIds = lessonVideoIds.Count == 0 ? new List<string>() : lessonVideoIds,
recruitId,
dateFormate = ts
});
var secretStr = AesEncrypt(payload, VideoKey);
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<JsonDocument> 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<string, string>
{
["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<PrelearningResult> 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<string, string>
{
["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<bool> 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<string, string>
{
["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 ─────────────────────────────────────
/// <summary>
/// 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=
/// </summary>
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)}");
}
}
/// <summary>Retrieve recruitId and ccCourseId for a given RAC_id from session data.</summary>
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 ─────────────────────────────────────
/// <summary>AES-CBC encryption for Zhidao API.</summary>
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);
}
/// <summary>AES-CBC decryption for Zhidao API responses (if needed).</summary>
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);
}
/// <summary>EV XOR confusion algorithm (ported from Python getEv).</summary>
public static string GetEv(List<string> 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<int> 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;
};
}
/// <summary>Build raw_ev parameter list for saveDatabaseIntervalTimeV2.</summary>
public static List<string> 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后缀
];
}
/// <summary>Seconds to HH:MM:SS format.</summary>
public static string Hms(double totalSeconds)
{
var ts = (int)totalSeconds;
return $"{ts / 3600:D2}:{ts % 3600 / 60:D2}:{ts % 60:D2}";
}
/// <summary>MD5 signature for Hike API.</summary>
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<string>();
var addedNames = new HashSet<string>(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<JsonDocument> 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] + "...";
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Playwright" Version="1.52.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -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
}
}
+161
View File
@@ -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<NodeConfig>(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<RegisterResponse>();
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<PollResponse>(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<TaskPayload>(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<SectionData> Sections);
public sealed record SectionData(string SectionName, List<string> Urls);
// Task payload (same structure as what backend puts in TaskDataJson)
public sealed record TaskPayload(List<ChapterData>? Chapters, List<AutomationStep>? AutomationSteps);
public sealed record AutomationStep(string Action, string? Url, string? Selector, int Timeout, int Seconds, int Pixels, string? Value);
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
<PackageReference Include="System.Net.Http.Json" Version="10.0.9" />
<PackageReference Include="System.Text.Json" Version="10.0.9" />
</ItemGroup>
</Project>
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5088
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>学习进度平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1938
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ConfigProvider } from 'ant-design-vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
</script>
<template>
<ConfigProvider
:locale="zhCN"
:theme="{
token: {
colorPrimary: '#c35a2d',
borderRadius: 6,
},
}"
>
<RouterView />
</ConfigProvider>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { uoocLogin } from '../lib/api';
import { usePlatformStore } from '../stores/platform';
import type { PlatformConnectionDto } from '../types/api';
declare global {
interface Window {
AliyunCaptchaConfig?: { region: string; prefix: string };
initAliyunCaptcha?: (config: Record<string, unknown>) => void;
}
}
const props = defineProps<{ platformId: number; connectionName: string }>();
const emit = defineEmits<{ success: [connection: PlatformConnectionDto]; close: [] }>();
const account = ref(''); const password = ref('');
const loading = ref(false); const error = ref('');
const captchaLoading = ref(false);
let scriptLoaded = false;
onMounted(() => { loadScript(); });
async function loadScript() {
if (scriptLoaded || document.querySelector('script[src*="aliyunCaptcha"]')) { scriptLoaded = true; return; }
return new Promise<void>(resolve => {
const s = document.createElement('script');
s.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js';
s.onload = () => { scriptLoaded = true; resolve(); };
s.onerror = () => resolve();
document.head.appendChild(s);
});
}
function waitReady(): Promise<void> {
return new Promise((resolve, reject) => {
let n = 0;
const check = () => { if (typeof window.initAliyunCaptcha === 'function') resolve(); else if (++n > 30) reject(new Error('验证码 SDK 超时。')); else setTimeout(check, 200); };
check();
});
}
function triggerCaptcha(): Promise<string> {
return new Promise((resolve, reject) => {
captchaLoading.value = true;
window.AliyunCaptchaConfig = { region: 'cn', prefix: 'dq1y8x' };
const m = document.getElementById('uooc-captcha-mount');
const t = document.getElementById('uooc-captcha-trigger');
if (!m || !t) { captchaLoading.value = false; reject(new Error('组件未就绪。')); return; }
m.innerHTML = '';
window.initAliyunCaptcha!({
SceneId: '137o3jmc', mode: 'popup',
element: '#uooc-captcha-mount', button: '#uooc-captcha-trigger',
success(p: string) { captchaLoading.value = false; resolve(p); },
fail() { captchaLoading.value = false; reject(new Error('验证未通过。')); },
getInstance() {},
slideStyle: { width: 360, height: 40 },
});
setTimeout(() => t.click(), 300);
});
}
async function submit() {
error.value = '';
const acc = account.value.trim(); const pwd = password.value.trim();
if (!acc) { error.value = '请输入手机号。'; return; }
if (!pwd) { error.value = '请输入密码。'; return; }
loading.value = true;
try {
await loadScript(); await waitReady();
const param = await triggerCaptcha();
const r = await uoocLogin({ platformId: props.platformId, connectionName: props.connectionName.trim() || null, account: acc, password: pwd, captchaVerifyParam: param });
await usePlatformStore().loadConnections();
if (r.connection) { message.success(r.message || '登录成功。'); emit('success', r.connection); }
} catch (err) { error.value = err instanceof Error ? err.message : '登录失败。'; }
finally { loading.value = false; }
}
</script>
<template>
<a-modal :open="true" title="登录 UOOC 学习平台" width="480px" :footer="null" :mask-closable="false" @cancel="emit('close')">
<a-alert v-if="error" type="error" :message="error" closable style="margin-bottom: 16px;" @close="error = ''" />
<a-form layout="vertical" :disabled="loading">
<a-form-item label="手机号" required>
<a-input v-model:value="account" placeholder="请输入 UOOC 手机号" autocomplete="tel" />
</a-form-item>
<a-form-item label="密码" required>
<a-input-password v-model:value="password" placeholder="请输入 UOOC 密码" autocomplete="current-password" />
<template #help>
密码将通过 HTTPS 加密传输至 UOOC 服务器
</template>
</a-form-item>
</a-form>
<div id="uooc-captcha-mount" />
<button id="uooc-captcha-trigger" type="button" style="display:none;">验证</button>
<a-tag v-if="captchaLoading" color="blue" style="margin: 8px 0;">请完成弹出的滑块验证...</a-tag>
<a-flex justify="end" :gap="8" style="margin-top: 16px;">
<a-button @click="emit('close')" :disabled="loading">取消</a-button>
<a-button type="primary" :loading="loading || captchaLoading" @click="submit">
{{ captchaLoading ? '等待验证...' : loading ? '登录中...' : '登录 UOOC 平台' }}
</a-button>
</a-flex>
</a-modal>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { zhihuishuLogin } from '../lib/api';
import { usePlatformStore } from '../stores/platform';
import type { PlatformConnectionDto } from '../types/api';
declare global {
interface Window {
initNECaptcha?: (config: Record<string, unknown>, onload: (instance: any) => void, onerror: (err: unknown) => void) => void;
}
}
const props = defineProps<{ platformId: number; connectionName: string }>();
const emit = defineEmits<{ success: [connection: PlatformConnectionDto]; close: [] }>();
const account = ref(''); const password = ref('');
const loading = ref(false); const error = ref('');
const captchaReady = ref(false);
let captchaInstance: any = null;
let pendingResolve: ((v: string) => void) | null = null;
let pendingReject: ((e: Error) => void) | null = null;
onMounted(() => { initCaptcha(); });
onUnmounted(() => {
const mount = document.getElementById('zhs-captcha-mount');
if (mount) mount.innerHTML = '';
});
async function initCaptcha(): Promise<void> {
// Load SDK script
if (!document.querySelector('script[src*="cstaticdun.126.net/load.min.js"]')) {
await new Promise<void>(resolve => {
const s = document.createElement('script');
s.src = 'https://cstaticdun.126.net/load.min.js';
s.onload = () => resolve();
s.onerror = () => resolve();
document.head.appendChild(s);
});
}
// Wait for SDK to be available
const ready = await new Promise<boolean>(resolve => {
let n = 0;
const check = () => {
if (typeof window.initNECaptcha === 'function') { resolve(true); return; }
if (++n > 50) { resolve(false); return; }
setTimeout(check, 200);
};
check();
});
if (!ready) {
console.error('[Zhihuishu] 网易易盾 SDK 未就绪');
return;
}
const mount = document.getElementById('zhs-captcha-mount');
if (!mount) return;
mount.innerHTML = '';
window.initNECaptcha!(
{
captchaId: '75f9f716460a422f89a628f50fd8cc2b',
element: '#zhs-captcha-mount',
mode: 'popup',
onVerify(err: unknown, data: { validate: string }) {
if (err) {
pendingReject?.(new Error('验证未通过。'));
} else {
pendingResolve?.(data.validate);
}
pendingResolve = null;
pendingReject = null;
},
},
(instance: any) => {
captchaInstance = instance;
captchaReady.value = true;
console.log('[Zhihuishu] 网易易盾 captcha ready');
},
(err: unknown) => {
console.error('[Zhihuishu] captcha init error:', err);
captchaReady.value = false;
},
);
}
function triggerCaptcha(): Promise<string> {
return new Promise((resolve, reject) => {
if (!captchaInstance) {
reject(new Error('验证码组件未就绪,请刷新页面后重试。'));
return;
}
pendingResolve = resolve;
pendingReject = reject;
captchaInstance.popUp();
});
}
async function submit() {
error.value = '';
const acc = account.value.trim(); const pwd = password.value.trim();
if (!acc) { error.value = '请输入手机号。'; return; }
if (!pwd) { error.value = '请输入密码。'; return; }
loading.value = true;
try {
if (!captchaInstance) {
await initCaptcha();
if (!captchaInstance) throw new Error('验证码初始化失败,请刷新页面后重试。');
}
const captchaValidate = await triggerCaptcha();
const r = await zhihuishuLogin({
platformId: props.platformId,
connectionName: props.connectionName.trim() || null,
account: acc,
password: pwd,
captchaValidate,
});
await usePlatformStore().loadConnections();
if (r.connection) {
message.success(r.message || '智慧树登录成功。');
emit('success', r.connection);
}
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败。';
} finally {
loading.value = false;
}
}
</script>
<template>
<a-modal :open="true" title="登录智慧树平台" width="480px" :footer="null" :mask-closable="false" @cancel="emit('close')">
<a-alert
v-if="!captchaReady"
type="warning"
message="正在加载网易易盾验证码组件,请稍候..."
style="margin-bottom: 16px;"
/>
<a-alert v-if="error" type="error" :message="error" closable style="margin-bottom: 16px;" @close="error = ''" />
<a-form layout="vertical" :disabled="loading">
<a-form-item label="手机号" required>
<a-input v-model:value="account" placeholder="请输入智慧树绑定的手机号" autocomplete="tel" />
</a-form-item>
<a-form-item label="密码" required>
<a-input-password v-model:value="password" placeholder="请输入智慧树密码" autocomplete="current-password" />
<template #help>
密码将通过加密传输至智慧树服务器系统不会存储明文密码
</template>
</a-form-item>
</a-form>
<div id="zhs-captcha-mount" style="margin: 8px 0; min-height: 40px;" />
<a-flex justify="end" :gap="8" style="margin-top: 16px;">
<a-button @click="emit('close')" :disabled="loading">取消</a-button>
<a-button type="primary" :loading="loading" :disabled="!captchaReady" @click="submit">
{{ loading ? '登录中...' : '登录智慧树' }}
</a-button>
</a-flex>
</a-modal>
</template>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, h, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BookOutlined, CloudOutlined, DashboardOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
SettingOutlined, TeamOutlined, KeyOutlined, AppstoreOutlined,
UserOutlined, LogoutOutlined, LockOutlined,
} from '@ant-design/icons-vue';
import { useAuthStore } from '../stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const collapsed = ref(false);
const currentTitle = computed(() => typeof route.meta.title === 'string' ? route.meta.title : '学习平台后台');
const selectedKeys = computed(() => {
const name = typeof route.name === 'string' ? route.name : '';
return [name];
});
// Build menu items with icons
function icon(component: any) { return () => h(component); }
const menuItems = computed(() => {
const items: any[] = [
{ key: 'courses', label: '刷课页', icon: icon(BookOutlined) },
{ key: 'progress', label: '查询进度', icon: icon(DashboardOutlined) },
];
if (auth.isAdmin) {
items.push(
{ type: 'divider' },
{ key: 'grp-admin', type: 'group', label: '管理' },
{ key: 'admin-users', label: '用户管理', icon: icon(TeamOutlined) },
{ key: 'admin-invites', label: '邀请码管理', icon: icon(KeyOutlined) },
{ key: 'admin-settings', label: '系统设置', icon: icon(SettingOutlined) },
{ key: 'admin-platforms', label: '平台管理', icon: icon(AppstoreOutlined) },
{ key: 'admin-tasks', label: '任务监控', icon: icon(DashboardOutlined) },
{ key: 'admin-nodes', label: '节点管理', icon: icon(CloudOutlined) },
);
}
return items;
});
function onMenuClick({ key }: { key: string }) {
if (!key.startsWith('grp-')) router.push({ name: key });
}
const dropdownItems = [
{ key: 'profile', label: '个人信息', icon: icon(UserOutlined) },
{ key: 'profile/password', label: '修改密码', icon: icon(LockOutlined) },
{ type: 'divider' },
{ key: 'logout', label: '退出登录', icon: icon(LogoutOutlined), danger: true },
];
async function onDropdownClick({ key }: { key: string }) {
if (key === 'logout') { auth.logout(); await router.replace('/login'); }
else router.push(`/${key}`);
}
</script>
<template>
<a-layout style="min-height: 100vh;">
<!-- Dark sidebar -->
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
breakpoint="lg"
theme="dark"
width="240"
>
<!-- Logo area -->
<a-flex align="center" style="height: 64px; padding: 0 24px;">
<a-typography-title
v-if="!collapsed"
:level="4"
style="color: #fff; margin: 0; white-space: nowrap;"
>
{{ auth.systemName }}
</a-typography-title>
</a-flex>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
theme="dark"
:items="menuItems"
@click="onMenuClick"
/>
</a-layout-sider>
<a-layout>
<!-- Header -->
<a-layout-header style="background: #fff; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 4px rgba(0,0,0,0.08); z-index: 1;">
<a-space :size="8">
<a-button
type="text"
:icon="collapsed ? h(MenuUnfoldOutlined) : h(MenuFoldOutlined)"
@click="collapsed = !collapsed"
/>
<a-breadcrumb class="header-breadcrumb">
<a-breadcrumb-item>首页</a-breadcrumb-item>
<a-breadcrumb-item>{{ currentTitle }}</a-breadcrumb-item>
</a-breadcrumb>
</a-space>
<a-space size="middle">
<a-dropdown>
<a-space style="cursor: pointer;">
<a-avatar size="small" style="background-color: #c35a2d;">
{{ auth.displayInitial }}
</a-avatar>
<span class="user-name">
<a-typography-text strong>{{ auth.currentUser?.displayName }}</a-typography-text>
</span>
</a-space>
<template #overlay>
<a-menu :items="dropdownItems" @click="onDropdownClick" />
</template>
</a-dropdown>
</a-space>
</a-layout-header>
<!-- Content -->
<a-layout-content class="app-content">
<RouterView />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.app-content {
padding: 24px;
background: #f5f5f5;
min-height: 280px;
}
@media (max-width: 576px) {
.app-content {
padding: 12px;
}
.header-breadcrumb {
display: none;
}
.user-name {
display: none;
}
}
</style>
+402
View File
@@ -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<T>(path: string, options: RequestOptions = {}): Promise<T> {
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<ProblemDetails> {
try {
return (await response.json()) as ProblemDetails;
} catch {
return {
title: response.statusText,
status: response.status,
};
}
}
export function getAuthConfig(): Promise<PublicAuthConfigResponse> {
return requestJson('/api/public/auth-config', { skipAuth: true });
}
export function registerUser(payload: RegisterRequest): Promise<AuthTokenResponse> {
return requestJson('/api/auth/register', {
method: 'POST',
body: payload,
skipAuth: true,
});
}
export function sendEmailCode(email: string): Promise<void> {
return requestJson('/api/auth/send-email-code', {
method: 'POST',
body: { email },
skipAuth: true,
});
}
export function loginUser(payload: LoginRequest): Promise<AuthTokenResponse> {
return requestJson('/api/auth/login', {
method: 'POST',
body: payload,
skipAuth: true,
});
}
export function getCurrentUser(): Promise<AuthUserDto> {
return requestJson('/api/auth/me');
}
export function changePassword(payload: ChangePasswordRequest): Promise<void> {
return requestJson('/api/auth/change-password', {
method: 'POST',
body: payload,
});
}
export function getAdminUsers(): Promise<AuthUserDto[]> {
return requestJson('/api/admin/users');
}
export function updateAdminUser(userId: number, payload: UpdateUserRequest): Promise<AuthUserDto> {
return requestJson(`/api/admin/users/${userId}`, {
method: 'PATCH',
body: payload,
});
}
export function getInviteCodes(): Promise<InviteCodeDto[]> {
return requestJson('/api/admin/invites');
}
export function createInviteCode(payload: CreateInviteCodeRequest): Promise<InviteCodeDto> {
return requestJson('/api/admin/invites', {
method: 'POST',
body: payload,
});
}
export function updateInviteCode(inviteId: number, payload: UpdateInviteCodeRequest): Promise<InviteCodeDto> {
return requestJson(`/api/admin/invites/${inviteId}`, {
method: 'PATCH',
body: payload,
});
}
export function getSystemSettings(): Promise<SystemSettingDto> {
return requestJson('/api/admin/settings');
}
export function updateSystemSettings(payload: UpdateSystemSettingRequest): Promise<SystemSettingDto> {
return requestJson('/api/admin/settings', {
method: 'PUT',
body: payload,
});
}
export function getAdminPlatforms(): Promise<PlatformSummaryDto[]> {
return requestJson('/api/admin/platforms');
}
export function createAdminPlatform(payload: SavePlatformDefinitionRequest): Promise<PlatformDefinitionDto> {
return requestJson('/api/admin/platforms', {
method: 'POST',
body: payload,
});
}
export function getAdminPlatform(platformId: number): Promise<PlatformDefinitionDto> {
return requestJson(`/api/admin/platforms/${platformId}`);
}
export function updateAdminPlatform(platformId: number, payload: SavePlatformDefinitionRequest): Promise<PlatformDefinitionDto> {
return requestJson(`/api/admin/platforms/${platformId}`, {
method: 'PUT',
body: payload,
});
}
export function updateAdminPlatformStatus(platformId: number, payload: PlatformStatusPatchRequest): Promise<PlatformDefinitionDto> {
return requestJson(`/api/admin/platforms/${platformId}/status`, {
method: 'PATCH',
body: payload,
});
}
export function cloneAdminPlatform(platformId: number): Promise<PlatformDefinitionDto> {
return requestJson(`/api/admin/platforms/${platformId}/clone`, {
method: 'POST',
});
}
export function getPlatforms(): Promise<PlatformSummaryDto[]> {
return requestJson('/api/platforms');
}
export function getPlatformLoginSchema(platformId: number): Promise<PlatformSchemaDto> {
return requestJson(`/api/platforms/${platformId}/schemas/login`);
}
export function getPlatformCourseQuerySchema(platformId: number): Promise<PlatformSchemaDto> {
return requestJson(`/api/platforms/${platformId}/schemas/course-query`);
}
export function getPlatformConnections(): Promise<PlatformConnectionDto[]> {
return requestJson('/api/platform-connections');
}
export function createPlatformConnection(payload: PlatformLoginStartRequest): Promise<PlatformLoginStartResponse> {
return requestJson('/api/platform-connections', {
method: 'POST',
body: payload,
});
}
export function reloginPlatformConnection(connectionId: number, payload: PlatformReloginRequest): Promise<PlatformLoginStartResponse> {
return requestJson(`/api/platform-connections/${connectionId}/relogin`, {
method: 'POST',
body: payload,
});
}
export function uoocLogin(payload: UoocLoginRequest): Promise<UoocLoginResponse> {
return requestJson('/api/platform-connections/uooc-login', {
method: 'POST',
body: payload,
});
}
export function zhihuishuLogin(payload: ZhihuishuLoginRequest): Promise<ZhihuishuLoginResponse> {
return requestJson('/api/platform-connections/zhihuishu-login', {
method: 'POST',
body: payload,
});
}
export function activatePlatformConnection(connectionId: number): Promise<void> {
return requestJson(`/api/platform-connections/${connectionId}/activate`, {
method: 'POST',
});
}
export function deletePlatformConnection(connectionId: number): Promise<void> {
return requestJson(`/api/platform-connections/${connectionId}`, {
method: 'DELETE',
});
}
export function getPlatformChallenge(challengeSessionId: string): Promise<ChallengeSessionDto> {
return requestJson(`/api/platform-challenges/${challengeSessionId}`);
}
export function queryPlatformCourses(connectionId: number, payload: PlatformCourseQueryRequest): Promise<CourseOptionsResponse> {
return requestJson(`/api/platform-connections/${connectionId}/courses/query`, {
method: 'POST',
body: payload,
});
}
export function getPlatformCatalog(connectionId: number, courseId: string): Promise<CatalogResponse> {
const search = new URLSearchParams({ courseId });
return requestJson(`/api/platform-connections/${connectionId}/catalog?${search.toString()}`);
}
export function getPlatformProgress(connectionId: number, courseId: string): Promise<CourseProgressResponse> {
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<UnitsResponse> {
const search = new URLSearchParams({ courseId, chapterId, sectionId });
return requestJson(`/api/platform-connections/${connectionId}/units?${search.toString()}`);
}
export function startBrush(payload: StartBrushRequest): Promise<BrushStatusDto> {
return requestJson('/api/brush/start', { method: 'POST', body: payload });
}
export function getBrushStatus(): Promise<BrushStatusDto[]> {
return requestJson('/api/brush/status');
}
export function stopBrush(): Promise<void> {
return requestJson('/api/brush/stop', { method: 'POST' });
}
export function retryBrush(): Promise<BrushStatusDto> {
return requestJson('/api/brush/retry', { method: 'POST' });
}
export function stopBrushTask(taskId: string): Promise<void> {
return requestJson(`/api/brush/${encodeURIComponent(taskId)}/stop`, { method: 'POST' });
}
export function retryBrushTask(taskId: string): Promise<BrushStatusDto> {
return requestJson(`/api/brush/${encodeURIComponent(taskId)}/retry`, { method: 'POST' });
}
export function deleteBrushTask(taskId: string): Promise<void> {
return requestJson(`/api/brush/${encodeURIComponent(taskId)}`, { method: 'DELETE' });
}
// Admin brush APIs
export function getAdminBrushTasks(): Promise<AdminBrushTaskDto[]> {
return requestJson('/api/admin/brush/tasks');
}
export function adminStopBrushTask(userId: number): Promise<void> {
return requestJson(`/api/admin/brush/stop/${userId}`, { method: 'POST' });
}
export function adminStopAllBrushTasks(): Promise<void> {
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<any[]> { 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<void> { return requestJson(`/api/admin/nodes/${nodeId}`, { method: 'DELETE' }); }
export function getNodeTasks(): Promise<any[]> { return requestJson('/api/admin/nodes/tasks'); }
export function cancelNodeTask(taskId: number): Promise<void> { 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;
}
+60
View File
@@ -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<T>(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<T>(key: string, value: T): void {
if (!isBrowser) {
return;
}
window.localStorage.setItem(key, JSON.stringify(value));
}
+23
View File
@@ -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();
+202
View File
@@ -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;
+155
View File
@@ -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<void> | 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<void> {
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<void> {
try {
this.authConfig = await getAuthConfig();
} catch {
this.authConfig = { registrationMode: 'open', systemName: 'UOOC Progress', requireEmailVerification: false };
}
},
async login(username: string, password: string): Promise<void> {
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<void> {
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<void> {
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 : '请求失败,请稍后重试。';
},
},
});
+144
View File
@@ -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<string, CourseOptionDto[]>;
selectedCourseByConnection: Record<string, string>;
progressSnapshots: Record<string, CourseProgressResponse>;
loadingConnections: string[];
loadingProgressKeys: string[];
errorByConnection: Record<string, string>;
messageByConnection: Record<string, string>;
}
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<string, string | null | undefined>): Promise<CourseOptionsResponse> {
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<CourseProgressResponse> {
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;
},
},
});
+249
View File
@@ -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<string, PlatformSchemaDto>;
courseQuerySchemas: Record<string, PlatformSchemaDto>;
isLoading: boolean;
error: string;
message: string;
currentChallenge: ChallengeSessionDto | null;
isPollingChallenge: boolean;
}
let challengePollTimer: ReturnType<typeof window.setTimeout> | 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<void> {
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<void> {
this.platforms = await getPlatforms();
},
async loadConnections(): Promise<void> {
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<PlatformSchemaDto> {
const key = String(platformId);
if (!this.loginSchemas[key]) {
this.loginSchemas[key] = await getPlatformLoginSchema(platformId);
}
return this.loginSchemas[key];
},
async ensureCourseQuerySchema(platformId: number): Promise<PlatformSchemaDto> {
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<string, string | null | undefined>,
): Promise<PlatformLoginStartResponse> {
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<string, string | null | undefined>,
): Promise<PlatformLoginStartResponse> {
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<void> {
await activatePlatformConnection(connectionId);
await this.loadConnections();
},
async deleteConnection(connectionId: number): Promise<void> {
await deletePlatformConnection(connectionId);
await this.loadConnections();
},
async startChallengePolling(challengeSessionId: string): Promise<void> {
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;
},
},
});
+513
View File
@@ -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<string, string | null | undefined>;
}
export interface PlatformReloginRequest {
fields: Record<string, string | null | undefined>;
}
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<string, string | null | undefined>;
}
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;
}
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { useAuthStore } from '../stores/auth';
const authStore = useAuthStore();
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
async function submit() {
if (!currentPassword.value || !newPassword.value) { message.error('请填写当前密码和新密码。'); return; }
if (newPassword.value !== confirmPassword.value) { message.error('两次输入的新密码不一致。'); return; }
loading.value = true;
try {
await authStore.updatePassword(currentPassword.value, newPassword.value);
currentPassword.value = ''; newPassword.value = ''; confirmPassword.value = '';
message.success('密码已更新。');
} catch (err) {
message.error(err instanceof Error ? err.message : '密码更新失败。');
} finally { loading.value = false; }
}
</script>
<template>
<a-card title="修改密码">
<a-form :label-col="{ xs: { span: 24 }, sm: { span: 4 } }" :wrapper-col="{ xs: { span: 24 }, sm: { span: 12 } }" style="max-width: 600px;">
<a-form-item label="当前密码" required>
<a-input-password v-model:value="currentPassword" />
</a-form-item>
<a-form-item label="新密码" required>
<a-input-password v-model:value="newPassword" placeholder="至少 6 位" />
</a-form-item>
<a-form-item label="确认新密码" required>
<a-input-password v-model:value="confirmPassword" placeholder="再次输入新密码" />
</a-form-item>
<a-form-item :wrapper-col="{ offset: 4 }">
<a-button type="primary" :loading="loading" @click="submit">
{{ loading ? '提交中...' : '更新密码' }}
</a-button>
</a-form-item>
</a-form>
</a-card>
</template>
+314
View File
@@ -0,0 +1,314 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import UoocLoginModal from '../components/UoocLoginModal.vue';
import ZhihuishuLoginModal from '../components/ZhihuishuLoginModal.vue';
import { getPlatformCatalog, startBrush } from '../lib/api';
import { useCoursesStore } from '../stores/courses';
import { usePlatformStore } from '../stores/platform';
import type { CatalogResponse, PlatformConnectionDto, PlatformFieldDefinitionDto } from '../types/api';
const platformStore = usePlatformStore();
const coursesStore = useCoursesStore();
const connectionName = ref('');
const loginFormValues = ref<Record<string, string>>({});
const courseQueryValues = ref<Record<string, string>>({});
const reloginConnectionId = ref<number | null>(null);
const showUoocModal = ref(false);
const showZhihuishuModal = ref(false);
const activeTab = ref('login');
const catalog = ref<CatalogResponse | null>(null);
const loadingCatalog = ref(false);
const catalogError = ref('');
const submittingBrush = ref(false);
// Selected chapter IDs
const selectedChapterIds = reactive(new Set<string>());
const selectedPlatformId = computed({
get: () => platformStore.selectedPlatformId ?? 0,
set: (v: number) => { if (v) { platformStore.selectPlatform(v); reloginConnectionId.value = null; showUoocModal.value = false; showZhihuishuModal.value = false; /* clear previous catalog */ catalog.value = null; catalogError.value = ''; selectedChapterIds.clear(); /* switch to login tab if no active connection for this platform */ const hasActive = platformStore.activeConnection?.platformId === v && platformStore.activeConnection?.status === 'connected'; if (!hasActive) activeTab.value = 'login'; } },
});
const loginSchema = computed(() => selectedPlatformId.value ? platformStore.loginSchemas[String(selectedPlatformId.value)] ?? null : null);
const isUoocPlatform = computed(() => platformStore.selectedPlatform?.slug === 'uooc');
const isZhihuishuPlatform = computed(() => platformStore.selectedPlatform?.slug === 'zhihuishu');
const activeConnection = computed(() => platformStore.activeConnection);
const activeCourseSchema = computed(() => activeConnection.value ? platformStore.courseQuerySchemas[String(activeConnection.value.platformId)] ?? null : null);
const activeCourseOptions = computed(() => activeConnection.value ? coursesStore.getCourseOptions(activeConnection.value.id) : []);
const selectedCourseId = computed({
get: () => coursesStore.getSelectedCourseId(activeConnection.value?.id),
set: (v: string) => { if (activeConnection.value) coursesStore.setSelectedCourse(activeConnection.value.id, v); },
});
const selectedSectionCount = computed(() => {
if (!catalog.value) return 0;
let n = 0;
for (const ch of catalog.value.chapters) {
if (selectedChapterIds.has(ch.id)) n += ch.sections.length;
}
return n;
});
watch(() => loginSchema.value, (s) => { loginFormValues.value = s ? applyDefaults(s.fields, loginFormValues.value) : {}; }, { immediate: true });
watch(() => activeCourseSchema.value, (s) => { courseQueryValues.value = s ? applyDefaults(s.fields, courseQueryValues.value) : {}; }, { immediate: true });
watch(selectedCourseId, async (cid) => { if (cid && activeConnection.value) await loadCatalog(); });
onMounted(async () => { if (platformStore.userId && platformStore.platforms.length === 0) await platformStore.bootstrapPlatformContext(); });
watch(activeConnection, async (conn) => { if (conn?.status === 'connected') { activeTab.value = 'course'; await loadCourses(); } });
// Auto-load catalog when entering course tab if a course is already selected
watch(activeTab, async (tab) => {
if (tab === 'course' && selectedCourseId.value && activeConnection.value && !catalog.value) {
await loadCatalog();
}
});
function applyDefaults(fields: PlatformFieldDefinitionDto[], cur: Record<string, string>) {
return Object.fromEntries(fields.map(f => [f.key, cur[f.key] ?? f.defaultValue ?? ''])) as Record<string, string>;
}
async function submitLogin() {
if (!selectedPlatformId.value) { message.warning('请选择平台。'); return; }
try {
if (reloginConnectionId.value) await platformStore.reloginConnection(reloginConnectionId.value, loginFormValues.value);
else await platformStore.createConnection(selectedPlatformId.value, connectionName.value, loginFormValues.value);
await platformStore.loadConnections();
if (platformStore.activeConnection?.status === 'connected') activeTab.value = 'course';
} catch (err) { message.error(err instanceof Error ? err.message : '连接失败。'); }
}
function onUoocLoginSuccess() { showUoocModal.value = false; activeTab.value = 'course'; }
function onZhihuishuLoginSuccess() { showZhihuishuModal.value = false; activeTab.value = 'course'; }
async function loadCourses() {
if (!activeConnection.value) return;
await platformStore.ensureCourseQuerySchema(activeConnection.value.platformId);
try { await coursesStore.queryCourses(activeConnection.value.id, courseQueryValues.value); }
catch (err) { message.error(err instanceof Error ? err.message : '课程加载失败。'); }
}
async function loadCatalog() {
if (!activeConnection.value || !selectedCourseId.value) return;
loadingCatalog.value = true; catalogError.value = '';
selectedChapterIds.clear();
try { catalog.value = await getPlatformCatalog(activeConnection.value.id, selectedCourseId.value); }
catch (err) { catalogError.value = err instanceof Error ? err.message : '加载失败。'; catalog.value = null; }
finally { loadingCatalog.value = false; }
}
function toggleChapter(chId: string, checked: boolean) {
if (checked) selectedChapterIds.add(chId);
else selectedChapterIds.delete(chId);
}
function clearSelection() { selectedChapterIds.clear(); }
async function startBrushing() {
if (!catalog.value || !selectedCourseId.value) return;
const chapters = catalog.value.chapters
.filter(ch => selectedChapterIds.has(ch.id))
.map(ch => ({
chapterId: ch.id,
chapterName: ch.name,
sections: ch.sections.map(sec => ({
sectionId: sec.id,
sectionName: sec.name,
videos: [] as any[],
})),
}));
if (chapters.length === 0) { message.warning('请至少选择一个章节。'); return; }
submittingBrush.value = true;
try {
await startBrush({ courseId: selectedCourseId.value, chapters });
clearSelection();
message.success('刷课任务已提交,可前往"查询进度"页面查看。');
} catch (err) { message.error(err instanceof Error ? err.message : '提交失败。'); }
finally { submittingBrush.value = false; }
}
async function activateConnection(c: PlatformConnectionDto) { await platformStore.activateConnection(c.id); }
function startRelogin(c: PlatformConnectionDto) { selectedPlatformId.value = c.platformId; reloginConnectionId.value = c.id; connectionName.value = c.connectionName; }
function resetToCreate() { reloginConnectionId.value = null; connectionName.value = ''; }
</script>
<template>
<a-card title="学习平台">
<!-- Platform selector always visible above tabs -->
<a-row :gutter="[16, 12]" style="margin-bottom: 16px;">
<a-col :xs="24" :sm="12" :md="8">
<a-select v-model:value="selectedPlatformId" placeholder="请选择平台" style="width: 100%;">
<a-select-option :value="0">请选择平台</a-select-option>
<a-select-option v-for="p in platformStore.platforms" :key="p.id" :value="p.id">{{ p.displayName }}</a-select-option>
</a-select>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-tag v-for="c in platformStore.connections.filter(x => x.platformId === selectedPlatformId)" :key="c.id"
:color="c.status === 'connected' ? 'success' : 'default'">
{{ c.connectionName }}
</a-tag>
</a-col>
</a-row>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="login" tab="连接平台">
<a-form layout="vertical">
<a-row :gutter="[16, 12]">
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="连接名称">
<a-input v-model:value="connectionName" placeholder="例如:我的 UOOC" :disabled="Boolean(reloginConnectionId)" />
</a-form-item>
</a-col>
</a-row>
<a-space v-if="platformStore.connections.filter(c => c.platformId === selectedPlatformId).length > 0" style="margin-bottom: 16px;" wrap>
<a-tag v-for="c in platformStore.connections.filter(x => x.platformId === selectedPlatformId)" :key="c.id" closable @close="startRelogin(c)">{{ c.connectionName }}</a-tag>
<a-button size="small" type="text" @click="resetToCreate">+ 新建</a-button>
</a-space>
<template v-if="isUoocPlatform && selectedPlatformId">
<a-button type="primary" size="large" @click="showUoocModal = true">登录 UOOC 平台</a-button>
</template>
<template v-else-if="isZhihuishuPlatform && selectedPlatformId">
<a-button type="primary" size="large" @click="showZhihuishuModal = true">登录智慧树平台</a-button>
</template>
<template v-else-if="loginSchema && selectedPlatformId">
<a-form-item v-for="field in loginSchema.fields.filter(f => f.type !== 'hidden')" :key="field.key" :label="field.label" :required="field.isRequired" :help="field.helpText ?? undefined">
<a-textarea v-if="field.type === 'textarea'" v-model:value="loginFormValues[field.key]" :rows="3" />
<a-select v-else-if="field.type === 'select'" v-model:value="loginFormValues[field.key]">
<a-select-option v-for="o in field.options" :key="o.value" :value="o.value">{{ o.label }}</a-select-option>
</a-select>
<a-input-password v-else-if="field.type === 'password'" v-model:value="loginFormValues[field.key]" />
<a-input-number v-else-if="field.type === 'number'" v-model:value="loginFormValues[field.key]" style="width: 100%;" />
<a-input v-else v-model:value="loginFormValues[field.key]" />
</a-form-item>
<a-button type="primary" @click="submitLogin">{{ reloginConnectionId ? '重新登录' : '登录并保存' }}</a-button>
</template>
<a-alert v-if="platformStore.currentChallenge" type="info" style="margin-top: 16px;" :message="platformStore.currentChallenge.message" />
</a-form>
</a-tab-pane>
<a-tab-pane key="course" tab="课程学习">
<a-empty v-if="!activeConnection" description="请先在连接平台标签页登录。" />
<template v-else>
<a-card size="small" title="步骤 1:选择课程" style="margin-bottom: 16px;">
<a-flex :gap="8" wrap="wrap" align="start">
<a-select v-model:value="selectedCourseId" placeholder="请先加载课程下拉" style="flex: 1; min-width: 200px; max-width: 400px;" :loading="coursesStore.isLoadingCourses(activeConnection?.id)">
<a-select-option v-for="c in activeCourseOptions" :key="c.value" :value="c.value">{{ c.label }}</a-select-option>
</a-select>
<a-button type="primary" :loading="coursesStore.isLoadingCourses(activeConnection?.id)" @click="loadCourses">
{{ coursesStore.isLoadingCourses(activeConnection?.id) ? '加载中...' : activeCourseOptions.length === 0 ? '加载课程列表' : '刷新' }}
</a-button>
</a-flex>
<div v-if="activeCourseSchema" style="margin-top: 8px;">
<a-flex :gap="8" wrap="wrap">
<a-input v-for="field in activeCourseSchema.fields.filter(f => f.type !== 'hidden')" :key="field.key" v-model:value="courseQueryValues[field.key]" :placeholder="field.label" :addon-before="field.label" style="min-width: 160px; max-width: 240px;" />
</a-flex>
</div>
<a-alert v-if="coursesStore.messageByConnection[String(activeConnection.id)]" type="info" :message="coursesStore.messageByConnection[String(activeConnection.id)]" style="margin-top: 8px;" />
</a-card>
<a-card v-if="selectedCourseId" size="small" title="步骤 2:章节列表">
<a-alert v-if="catalogError" type="error" :message="catalogError" style="margin-bottom: 12px;" />
<a-skeleton v-if="loadingCatalog" active :paragraph="{ rows: 6 }" />
<template v-else>
<a-empty v-if="catalog && catalog.chapters.length === 0" description="暂无章节数据" />
<a-collapse v-if="catalog">
<a-collapse-panel v-for="ch in catalog.chapters" :key="ch.id">
<template #header>
<a-flex align="center" :gap="8" wrap="wrap">
<a-checkbox
:checked="selectedChapterIds.has(ch.id)"
@change="(e: any) => toggleChapter(ch.id, e.target.checked)"
@click.stop
/>
<strong>{{ ch.name }}</strong>
<a-tag :color="ch.finished ? 'green' : ch.learning ? 'blue' : 'default'">
{{ ch.finished ? '已完成' : ch.learning ? '学习中' : '未开始' }}
</a-tag>
<a-typography-text type="secondary">
{{ ch.sections.length }}
</a-typography-text>
</a-flex>
</template>
<a-list :data-source="ch.sections" size="small">
<template #renderItem="{ item: sec }">
<a-list-item>
{{ sec.name }}
<template #actions>
<a-tag :color="sec.finished ? 'green' : sec.learning ? 'blue' : 'default'">
{{ sec.finished ? '已完成' : sec.learning ? '学习中' : '-' }}
</a-tag>
</template>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
</a-collapse>
</template>
</a-card>
</template>
</a-tab-pane>
</a-tabs>
<!-- Selection bar -->
<div v-if="selectedChapterIds.size > 0" class="selection-bar">
<strong>已选 {{ selectedChapterIds.size }} {{ selectedSectionCount }} </strong>
<a-space>
<a-button @click="clearSelection">清空</a-button>
<a-button type="primary" :loading="submittingBrush" @click="startBrushing">开始刷课</a-button>
</a-space>
</div>
<UoocLoginModal
v-if="showUoocModal && isUoocPlatform && selectedPlatformId"
:platform-id="selectedPlatformId"
:connection-name="connectionName"
@success="onUoocLoginSuccess"
@close="showUoocModal = false"
/>
<ZhihuishuLoginModal
v-if="showZhihuishuModal && isZhihuishuPlatform && selectedPlatformId"
:platform-id="selectedPlatformId"
:connection-name="connectionName"
@success="onZhihuishuLoginSuccess"
@close="showZhihuishuModal = false"
/>
</a-card>
</template>
<style scoped>
.selection-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 2px solid #c35a2d;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 50;
box-shadow: 0 -4px 12px rgba(0,0,0,0.1);
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 576px) {
.selection-bar {
padding: 10px 12px;
font-size: 13px;
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<template>
<a-result
status="403"
title="403"
sub-title="当前账号没有访问这个页面的权限"
>
<template #extra>
<RouterLink to="/courses">
<a-button type="primary">返回刷课页</a-button>
</RouterLink>
</template>
</a-result>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { usePlatformStore } from '../stores/platform';
const platformStore = usePlatformStore();
const activeId = computed(() => platformStore.activeConnection?.id ?? null);
onMounted(async () => {
if (platformStore.userId && platformStore.platforms.length === 0) {
await platformStore.bootstrapPlatformContext();
}
});
async function activate(id: number) { await platformStore.activateConnection(id); }
async function remove(id: number) { await platformStore.deleteConnection(id); message.success('已删除。'); }
function tagColor(status: string) {
switch (status) { case 'connected': return 'green'; case 'challenge_pending': return 'blue'; case 'failed': return 'red'; default: return 'orange'; }
}
function tagLabel(status: string) {
switch (status) { case 'connected': return '已连接'; case 'challenge_pending': return '等待验证'; case 'failed': return '失败'; default: return '处理中'; }
}
</script>
<template>
<a-card title="平台连接中心">
<template #extra>
<RouterLink to="/courses"><a-button type="primary">去刷课页</a-button></RouterLink>
</template>
<a-empty v-if="platformStore.connections.length === 0" description="暂无连接" />
<a-list v-else :data-source="platformStore.connections">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-space>
{{ item.connectionName }}
<a-tag :color="tagColor(item.status)">{{ tagLabel(item.status) }}</a-tag>
</a-space>
</template>
<template #description>
{{ item.platformName }}
<span v-if="item.platformUserLabel"> · {{ item.platformUserLabel }}</span>
<br />
创建 {{ new Date(item.createdAt).toLocaleString() }}
· 最后登录 {{ item.lastSuccessfulLoginAt ? new Date(item.lastSuccessfulLoginAt).toLocaleString() : '-' }}
· 校验 {{ item.lastValidatedAt ? new Date(item.lastValidatedAt).toLocaleString() : '-' }}
<a-alert v-if="item.lastError" type="error" :message="item.lastError" style="margin-top: 8px;" />
</template>
</a-list-item-meta>
<template #actions>
<a-tag :color="item.id === activeId ? 'green' : 'default'">
{{ item.id === activeId ? '当前' : '可切换' }}
</a-tag>
<a-button size="small" :disabled="item.id === activeId" @click="activate(item.id)">设为当前</a-button>
<a-button size="small" danger @click="remove(item.id)">删除</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useAuthStore } from '../stores/auth';
const auth = useAuthStore();
</script>
<template>
<a-card title="个人信息">
<template #extra>
<a-tag :color="auth.isAdmin ? 'blue' : 'default'">{{ auth.roleLabel }}</a-tag>
</template>
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label="用户名">{{ auth.currentUser?.username }}</a-descriptions-item>
<a-descriptions-item label="显示名">{{ auth.currentUser?.displayName }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="auth.currentUser?.status === 'active' ? 'green' : 'red'">
{{ auth.currentUser?.status === 'active' ? '正常' : '停用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="最近登录">
{{ auth.currentUser?.lastLoginAt ? new Date(auth.currentUser.lastLoginAt).toLocaleString() : '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ auth.currentUser?.createdAt ? new Date(auth.currentUser.createdAt).toLocaleString() : '暂无' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</template>
+205
View File
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { getBrushStatus, retryBrush, stopBrushTask, retryBrushTask, deleteBrushTask } from '../lib/api';
import type { BrushStatusDto } from '../types/api';
const tasks = ref<BrushStatusDto[]>([]);
const actionLoading = ref<Record<string, boolean>>({});
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(startBrushPolling);
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer); });
async function fetchStatus() {
try {
tasks.value = await getBrushStatus();
} catch { tasks.value = []; }
}
function startBrushPolling() {
if (pollTimer) clearInterval(pollTimer);
fetchStatus();
pollTimer = setInterval(fetchStatus, 2000);
}
function setLoading(taskId: string, v: boolean) {
actionLoading.value = { ...actionLoading.value, [taskId]: v };
}
async function handleStop(taskId: string) {
setLoading(taskId, true);
try {
await stopBrushTask(taskId);
message.success('已停止。');
} catch (err) {
message.error(err instanceof Error ? err.message : '操作失败。');
} finally { setLoading(taskId, false); }
}
async function handleRetry(taskId: string) {
setLoading(taskId, true);
try {
await retryBrushTask(taskId);
message.success('已重新开始。');
} catch (err) {
message.error(err instanceof Error ? err.message : '重试失败。');
} finally { setLoading(taskId, false); }
}
async function handleDelete(taskId: string) {
setLoading(taskId, true);
try {
await deleteBrushTask(taskId);
message.success('已删除。');
} catch (err) {
message.error(err instanceof Error ? err.message : '删除失败。');
} finally { setLoading(taskId, false); }
}
const platformLabel = (slug: string) => slug === 'zhihuishu' ? '智慧树' : 'UOOC';
const statusColor = (status: string) => {
if (status === 'Running') return 'processing';
if (status === 'Queued') return 'warning';
if (status === 'Completed') return 'success';
if (status === 'Failed') return 'error';
return 'default';
};
const statusLabel = (status: string) => {
if (status === 'Running') return '进行中';
if (status === 'Queued') return '排队中';
if (status === 'Completed') return '已完成';
if (status === 'Failed') return '已失败';
if (status === 'Stopped') return '已停止';
return status;
};
function timeAgo(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
return `${Math.floor(diff / 86400)} 天前`;
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds)}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${Math.round(seconds % 60)}`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h} 小时 ${m}`;
}
</script>
<template>
<a-card title="刷课进度">
<a-empty v-if="tasks.length === 0" description="当前没有刷课任务。" />
<template v-else>
<a-alert
v-if="tasks[0].queuedCount > 0"
type="info"
show-icon
style="margin-bottom: 12px;"
>
<template #message>
还有 {{ tasks[0].queuedCount }} 个任务在排队等待执行
</template>
</a-alert>
<a-row :gutter="[12, 12]">
<a-col v-for="t in tasks" :key="t.taskId" :xs="24" :lg="12">
<a-card size="small">
<template #title>
{{ platformLabel(t.platformSlug) }} · {{ t.courseId }}
</template>
<template #extra>
<a-tag :color="statusColor(t.status)">{{ statusLabel(t.status) }}</a-tag>
</template>
<a-typography-text type="secondary" style="font-size: 12px;">
{{ t.createdAt ? timeAgo(t.createdAt) + '提交' : '' }}
<template v-if="t.status === 'Running' || t.status === 'Queued'">
· 已运行 {{ formatDuration(t.durationSeconds) }}
</template>
<template v-else>
· 耗时 {{ formatDuration(t.durationSeconds) }}
</template>
</a-typography-text>
<div style="font-weight: 500; margin-top: 4px; font-size: 13px; color: #666;">
{{ t.chaptersSummary }}
</div>
<a-descriptions size="small" :column="1" style="margin: 8px 0;">
<a-descriptions-item label="进度">
{{ t.completedVideos }} / {{ t.totalVideos }}
<a-tag
v-if="t.retryCount > 0"
:color="t.retryCount >= 3 ? 'error' : 'warning'"
style="margin-left: 8px;"
>
重试 {{ t.retryCount }}/3
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="t.status === 'Running'" label="章节">{{ t.currentChapterName || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="t.status === 'Running'" label="小节">{{ t.currentSectionName || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="t.status === 'Running'" label="资源">{{ t.currentVideoTitle || '-' }}</a-descriptions-item>
</a-descriptions>
<a-alert
v-if="t.lastError"
type="error"
:message="t.lastError"
style="margin-bottom: 8px;"
/>
<a-row v-if="t.status === 'Running'" align="middle" style="margin-bottom: 8px;">
<a-progress
:percent="t.currentVideoLength > 0 ? Math.min(100, Math.round(t.currentVideoPos / t.currentVideoLength * 100)) : 0"
:format="() => `${Math.round(t.currentVideoPos)}s / ${Math.round(t.currentVideoLength || 0)}s`"
style="flex: 1;"
/>
</a-row>
<a-space style="margin-top: 4px;">
<a-popconfirm
v-if="t.status === 'Running' || t.status === 'Queued'"
title="确定停止该任务?"
ok-text="确定"
cancel-text="取消"
@confirm="handleStop(t.taskId)"
>
<a-button size="small" danger :loading="actionLoading[t.taskId]">
停止
</a-button>
</a-popconfirm>
<a-button
v-if="t.status === 'Failed'"
type="primary"
size="small"
:loading="actionLoading[t.taskId]"
@click="handleRetry(t.taskId)"
>
重试
</a-button>
<a-popconfirm
v-if="t.status === 'Completed' || t.status === 'Failed' || t.status === 'Stopped'"
title="确定删除该任务记录?"
ok-text="确定删除"
cancel-text="取消"
@confirm="handleDelete(t.taskId)"
>
<a-button size="small" :loading="actionLoading[t.taskId]">
删除
</a-button>
</a-popconfirm>
</a-space>
</a-card>
</a-col>
</a-row>
</template>
</a-card>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { createInviteCode, getInviteCodes, updateInviteCode } from '../../lib/api';
import type { InviteCodeDto } from '../../types/api';
const invites = ref<InviteCodeDto[]>([]);
const loading = ref(false);
const creating = ref(false);
const savingId = ref<number | null>(null);
const form = reactive({ code: '', maxUses: 1, expiresAt: '' });
async function load() {
loading.value = true;
try { invites.value = await getInviteCodes(); }
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
finally { loading.value = false; }
}
async function create() {
creating.value = true;
try {
const inv = await createInviteCode({
code: form.code.trim() || undefined,
maxUses: Number(form.maxUses) || 1,
expiresAt: form.expiresAt ? new Date(form.expiresAt).toISOString() : undefined,
});
invites.value = [inv, ...invites.value];
message.success(`已创建 ${inv.code}`);
form.code = ''; form.maxUses = 1; form.expiresAt = '';
} catch (err) { message.error(err instanceof Error ? err.message : '创建失败。'); }
finally { creating.value = false; }
}
async function toggle(inv: InviteCodeDto) {
savingId.value = inv.id;
try {
const u = await updateInviteCode(inv.id, { status: inv.status === 'active' ? 'disabled' : 'active' });
invites.value = invites.value.map(x => x.id === u.id ? u : x);
message.success(`邀请码 ${u.code}${u.status === 'active' ? '启用' : '停用'}`);
} catch (err) { message.error(err instanceof Error ? err.message : '更新失败。'); }
finally { savingId.value = null; }
}
onMounted(load);
const columns = [
{ title: '邀请码', dataIndex: 'code' },
{ title: '创建人', dataIndex: 'createdByDisplayName' },
{ title: '使用', key: 'usage' },
{ title: '过期时间', key: 'expires' },
{ title: '状态', key: 'status' },
{ title: '操作', key: 'action', width: 80 },
];
</script>
<template>
<a-card title="邀请码管理">
<template #extra>
<a-button @click="load" :loading="loading">刷新</a-button>
</template>
<a-space style="margin-bottom: 16px;">
<a-input v-model:value="form.code" placeholder="邀请码(留空自动生成)" style="width: 200px;" />
<a-input-number v-model:value="form.maxUses" :min="1" style="width: 120px;" placeholder="可用次数" />
<a-date-picker v-model:value="form.expiresAt" placeholder="过期时间(可选)" show-time />
<a-button type="primary" :loading="creating" @click="create">{{ creating ? '...' : '创建' }}</a-button>
</a-space>
<a-table :data-source="invites" :columns="columns" :loading="loading" row-key="id" size="small" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'usage'">{{ record.usedCount }} / {{ record.maxUses }}</template>
<template v-else-if="column.key === 'expires'">
{{ record.expiresAt ? new Date(record.expiresAt).toLocaleString('zh-CN') : '不过期' }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status === 'active' ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button size="small" :loading="savingId === record.id" @click="toggle(record)">
{{ record.status === 'active' ? '停用' : '启用' }}
</a-button>
</template>
</template>
</a-table>
</a-card>
</template>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { generateNodeToken, getNodes, getNodeTasks, deleteNode, cancelNodeTask } from '../../lib/api';
interface NodeInfo { id: number; name: string; token: string; lastIp: string | null; lastHeartbeat: string; isOnline: boolean; createdAt: string; }
interface TaskInfo { id: number; nodeId: number | null; node: NodeInfo | null; userId: number; courseName: string; platformUrl: string; status: string; totalSteps: number; completedSteps: number; currentStep: string | null; lastError: string | null; updatedAt: string; }
const nodes = ref<NodeInfo[]>([]);
const tasks = ref<TaskInfo[]>([]);
const newToken = ref('');
let timer: any = null;
onMounted(async () => { await refresh(); timer = setInterval(refresh, 5000); });
onUnmounted(() => clearInterval(timer));
async function refresh() {
try { nodes.value = await getNodes(); } catch { /* */ }
try { tasks.value = await getNodeTasks(); } catch { /* */ }
}
async function genToken() {
try { const r = await generateNodeToken(); newToken.value = r.token; }
catch { message.error('生成失败'); }
}
async function delNode(id: number) {
try { await deleteNode(id); message.success('已删除'); await refresh(); }
catch { message.error('删除失败'); }
}
async function cancelTask(id: number) {
try { await cancelNodeTask(id); message.success('已取消'); await refresh(); }
catch { message.error('取消失败'); }
}
const nodeCols = [
{ title: 'ID', dataIndex: 'id', width: 50 },
{ title: '名称', dataIndex: 'name' },
{ title: '在线', key: 'online', width: 60 },
{ title: 'IP', dataIndex: 'lastIp', width: 130 },
{ title: '最后心跳', key: 'hb', width: 160 },
{ title: '操作', key: 'action', width: 60 },
];
const taskCols = [
{ title: '课程', dataIndex: 'courseName', ellipsis: true },
{ title: '状态', key: 'status', width: 70 },
{ title: '进度', key: 'progress', width: 90 },
{ title: '节点', key: 'node', width: 100 },
{ title: '当前步骤', dataIndex: 'currentStep', ellipsis: true, width: 180 },
{ title: '错误', dataIndex: 'lastError', ellipsis: true, width: 150 },
{ title: '操作', key: 'action', width: 60 },
];
</script>
<template>
<a-card title="节点管理">
<template #extra>
<a-space>
<a-button @click="genToken">生成 Token</a-button>
<a-button @click="refresh">刷新</a-button>
</a-space>
</template>
<a-alert v-if="newToken" type="success" closable @close="newToken = ''" style="margin-bottom: 12px;">
Token<a-typography-text copyable code>{{ newToken }}</a-typography-text>仅显示一次请复制保存
</a-alert>
<a-empty v-if="nodes.length === 0" description="暂无节点,先生成 Token,然后在 Windows 上运行 agent。" />
<a-table v-else :data-source="nodes" :columns="nodeCols" row-key="id" size="small" :pagination="false" style="margin-bottom: 16px;">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'online'">
<a-tag :color="record.isOnline ? 'green' : 'red'">{{ record.isOnline ? '在线' : '离线' }}</a-tag>
</template>
<template v-else-if="column.key === 'hb'">{{ new Date(record.lastHeartbeat).toLocaleString() }}</template>
<template v-else-if="column.key === 'action'">
<a-button size="small" danger @click="delNode(record.id)">删除</a-button>
</template>
</template>
</a-table>
</a-card>
<a-card title="任务列表" style="margin-top: 16px;">
<a-empty v-if="tasks.length === 0" description="暂无任务" />
<a-table v-else :data-source="tasks" :columns="taskCols" row-key="id" size="small" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'Running' ? 'processing' : record.status === 'Completed' ? 'success' : record.status === 'Failed' ? 'error' : 'default'">
{{ record.status === 'Running' ? '执行中' : record.status === 'Completed' ? '完成' : record.status === 'Failed' ? '失败' : '等待' }}
</a-tag>
</template>
<template v-else-if="column.key === 'progress'">{{ record.completedSteps }}/{{ record.totalSteps }}</template>
<template v-else-if="column.key === 'node'">{{ record.node?.name || '-' }}</template>
<template v-else-if="column.key === 'action'">
<a-button v-if="record.status === 'Pending' || record.status === 'Running'" size="small" danger @click="cancelTask(record.id)">取消</a-button>
</template>
</template>
</a-table>
</a-card>
</template>
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import {
cloneAdminPlatform, createAdminPlatform, getAdminPlatform, getAdminPlatforms,
updateAdminPlatform, updateAdminPlatformStatus,
} from '../../lib/api';
import type {
CatalogMappingDto, CourseOptionMappingDto, PlatformCookieMappingDto, PlatformDefinitionDto,
PlatformFieldDefinitionDto, PlatformOutputVariableDto, PlatformSummaryDto,
PlatformWorkflowStepDto, SavePlatformDefinitionRequest, SelectOptionDto, UnitMappingDto,
} from '../../types/api';
interface FieldEditor {
scope: 'login' | 'course_query'; key: string; label: string; type: PlatformFieldDefinitionDto['type'];
isRequired: boolean; displayOrder: number; placeholder: string; helpText: string;
defaultValue: string; isSensitive: boolean; optionsText: string;
}
interface StepEditor {
scope: PlatformWorkflowStepDto['scope']; stepKey: string; displayName: string; displayOrder: number;
stepType: PlatformWorkflowStepDto['stepType']; httpMethod: string; urlTemplate: string;
queryTemplateJson: string; headersTemplateJson: string; bodyTemplateJson: string; contentType: string;
successPath: string; successExpectedValue: string; platformUserLabelExpression: string;
outputCookiesText: string; outputVariablesText: string; courseOptionMappingText: string;
catalogMappingText: string; unitMappingText: string; browserSuccessUrlContains: string;
browserSuccessCookieName: string; browserWaitForSelector: string; browserTimeoutSeconds: string;
isEnabled: boolean;
}
const loading = ref(false); const saving = ref(false);
const platforms = ref<PlatformSummaryDto[]>([]);
const editingPlatformId = ref<number | null>(null);
const activeTab = ref('list');
const editor = reactive({
slug: '', displayName: '', description: '', status: 'draft' as SavePlatformDefinitionRequest['status'],
enableBrowserChallenge: false, courseQueryStepKey: '',
supportsCatalog: true, supportsUnits: true, supportsProgress: true, challengeTimeoutSeconds: 600,
fields: [] as FieldEditor[], steps: [] as StepEditor[],
});
const courseQueryStepOptions = computed(() =>
editor.steps.filter(s => s.scope === 'course_query').map(s => s.stepKey).filter(Boolean));
onMounted(async () => { await loadPlatforms(); resetEditor(); });
async function loadPlatforms() {
loading.value = true;
try { platforms.value = await getAdminPlatforms(); }
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
finally { loading.value = false; }
}
function resetEditor() {
editingPlatformId.value = null;
Object.assign(editor, {
slug: '', displayName: '', description: '', status: 'draft' as const,
enableBrowserChallenge: false, courseQueryStepKey: '',
supportsCatalog: true, supportsUnits: true, supportsProgress: true, challengeTimeoutSeconds: 600,
fields: [createField('login'), createField('course_query')], steps: [createStep('login')],
});
}
function createField(s: 'login' | 'course_query'): FieldEditor {
return { scope: s, key: '', label: '', type: 'text', isRequired: true,
displayOrder: editor.fields.filter(f => f.scope === s).length + 1,
placeholder: '', helpText: '', defaultValue: '', isSensitive: false, optionsText: '[]' };
}
function createStep(s: PlatformWorkflowStepDto['scope']): StepEditor {
return { scope: s, stepKey: '', displayName: '',
displayOrder: editor.steps.filter(st => st.scope === s).length + 1,
stepType: 'http_request', httpMethod: 'GET', urlTemplate: '', queryTemplateJson: '',
headersTemplateJson: '', bodyTemplateJson: '', contentType: '', successPath: '',
successExpectedValue: '', platformUserLabelExpression: '', outputCookiesText: '[]',
outputVariablesText: '[]', courseOptionMappingText: '', catalogMappingText: '',
unitMappingText: '', browserSuccessUrlContains: '', browserSuccessCookieName: '',
browserWaitForSelector: '', browserTimeoutSeconds: '', isEnabled: true };
}
async function editPlatform(id: number) {
loading.value = true;
try { fillEditor(await getAdminPlatform(id)); activeTab.value = 'editor'; }
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
finally { loading.value = false; }
}
async function duplicatePlatform(id: number) {
await cloneAdminPlatform(id); message.success('已复制。'); await loadPlatforms();
}
async function toggleStatus(p: PlatformSummaryDto) {
await updateAdminPlatformStatus(p.id, { status: p.status === 'active' ? 'disabled' : 'active' });
await loadPlatforms();
}
function startNew() { resetEditor(); activeTab.value = 'editor'; }
async function submit() {
saving.value = true;
try {
const payload = buildPayload();
if (editingPlatformId.value) {
await updateAdminPlatform(editingPlatformId.value, payload); message.success('已更新。');
} else {
await createAdminPlatform(payload); message.success('已创建。');
}
await loadPlatforms(); activeTab.value = 'list';
} catch (err) { message.error(err instanceof Error ? err.message : '保存失败。'); }
finally { saving.value = false; }
}
function buildPayload(): SavePlatformDefinitionRequest {
return {
slug: editor.slug.trim(), displayName: editor.displayName.trim(), description: editor.description.trim(),
status: editor.status, enableBrowserChallenge: editor.enableBrowserChallenge,
courseQueryStepKey: editor.courseQueryStepKey.trim() || null,
supportsCatalog: editor.supportsCatalog, supportsUnits: editor.supportsUnits, supportsProgress: editor.supportsProgress,
challengeTimeoutSeconds: Number(editor.challengeTimeoutSeconds) || 600,
fields: editor.fields.map(f => ({
scope: f.scope, key: f.key.trim(), label: f.label.trim(), type: f.type,
isRequired: f.isRequired, displayOrder: Number(f.displayOrder),
placeholder: f.placeholder.trim() || null, helpText: f.helpText.trim() || null,
defaultValue: f.defaultValue.trim() || null, isSensitive: f.isSensitive,
options: parseJson<SelectOptionDto[]>(f.optionsText, []),
})),
steps: editor.steps.map(s => ({
scope: s.scope, stepKey: s.stepKey.trim(), displayName: s.displayName.trim(),
displayOrder: Number(s.displayOrder), stepType: s.stepType,
httpMethod: s.httpMethod.trim() || 'GET', urlTemplate: s.urlTemplate.trim() || null,
queryTemplateJson: s.queryTemplateJson.trim() || null, headersTemplateJson: s.headersTemplateJson.trim() || null,
bodyTemplateJson: s.bodyTemplateJson.trim() || null, contentType: s.contentType.trim() || null,
successPath: s.successPath.trim() || null, successExpectedValue: s.successExpectedValue.trim() || null,
platformUserLabelExpression: s.platformUserLabelExpression.trim() || null,
outputCookies: parseJson<PlatformCookieMappingDto[]>(s.outputCookiesText, []),
outputVariables: parseJson<PlatformOutputVariableDto[]>(s.outputVariablesText, []),
courseOptionMapping: parseJson<CourseOptionMappingDto | null>(s.courseOptionMappingText, null),
catalogMapping: parseJson<CatalogMappingDto | null>(s.catalogMappingText, null),
unitMapping: parseJson<UnitMappingDto | null>(s.unitMappingText, null),
browserSuccessUrlContains: s.browserSuccessUrlContains.trim() || null,
browserSuccessCookieName: s.browserSuccessCookieName.trim() || null,
browserWaitForSelector: s.browserWaitForSelector.trim() || null,
browserTimeoutSeconds: s.browserTimeoutSeconds.trim() ? Number(s.browserTimeoutSeconds) : null,
isEnabled: s.isEnabled,
})),
};
}
function fillEditor(p: PlatformDefinitionDto) {
editingPlatformId.value = p.id;
editor.slug = p.slug; editor.displayName = p.displayName; editor.description = p.description;
editor.status = p.status; editor.enableBrowserChallenge = p.enableBrowserChallenge;
editor.courseQueryStepKey = p.courseQueryStepKey ?? '';
editor.supportsCatalog = p.supportsCatalog; editor.supportsUnits = p.supportsUnits;
editor.supportsProgress = p.supportsProgress; editor.challengeTimeoutSeconds = p.challengeTimeoutSeconds;
editor.fields = [...p.loginFields, ...p.courseQueryFields].map(f => ({
scope: f.scope, key: f.key, label: f.label, type: f.type, isRequired: f.isRequired,
displayOrder: f.displayOrder, placeholder: f.placeholder ?? '', helpText: f.helpText ?? '',
defaultValue: f.defaultValue ?? '', isSensitive: f.isSensitive,
optionsText: JSON.stringify(f.options, null, 2),
}));
editor.steps = [...p.loginSteps, ...p.courseQuerySteps, ...p.catalogSteps, ...p.unitSteps, ...p.progressSteps].map(s => ({
scope: s.scope, stepKey: s.stepKey, displayName: s.displayName, displayOrder: s.displayOrder,
stepType: s.stepType, httpMethod: s.httpMethod,
urlTemplate: s.urlTemplate ?? '', queryTemplateJson: s.queryTemplateJson ?? '',
headersTemplateJson: s.headersTemplateJson ?? '', bodyTemplateJson: s.bodyTemplateJson ?? '',
contentType: s.contentType ?? '', successPath: s.successPath ?? '',
successExpectedValue: s.successExpectedValue ?? '', platformUserLabelExpression: s.platformUserLabelExpression ?? '',
outputCookiesText: JSON.stringify(s.outputCookies, null, 2),
outputVariablesText: JSON.stringify(s.outputVariables, null, 2),
courseOptionMappingText: s.courseOptionMapping ? JSON.stringify(s.courseOptionMapping, null, 2) : '',
catalogMappingText: s.catalogMapping ? JSON.stringify(s.catalogMapping, null, 2) : '',
unitMappingText: s.unitMapping ? JSON.stringify(s.unitMapping, null, 2) : '',
browserSuccessUrlContains: s.browserSuccessUrlContains ?? '', browserSuccessCookieName: s.browserSuccessCookieName ?? '',
browserWaitForSelector: s.browserWaitForSelector ?? '', browserTimeoutSeconds: s.browserTimeoutSeconds ? String(s.browserTimeoutSeconds) : '',
isEnabled: s.isEnabled,
}));
}
function parseJson<T>(value: string, fallback: T): T { return value.trim() ? JSON.parse(value) as T : fallback; }
const platformColumns = [
{ title: '标识', dataIndex: 'slug' },
{ title: '名称', dataIndex: 'displayName' },
{ title: '描述', dataIndex: 'description', ellipsis: true },
{ title: '状态', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 200 },
];
</script>
<template>
<a-card title="平台管理">
<template #extra>
<a-button type="primary" @click="startNew"><PlusOutlined /> 新建平台</a-button>
</template>
<a-table :data-source="platforms" :columns="platformColumns" :loading="loading" row-key="id" size="small" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : record.status === 'disabled' ? 'red' : 'orange'">
{{ record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button size="small" @click="editPlatform(record.id)">编辑</a-button>
<a-button size="small" @click="duplicatePlatform(record.id)">复制</a-button>
<a-button size="small" type="text" @click="toggleStatus(record)">
{{ record.status === 'active' ? '停用' : '启用' }}
</a-button>
</a-space>
</template>
</template>
</a-table>
<a-divider />
<!-- Editor -->
<a-card :title="editingPlatformId ? '编辑平台' : '新建平台'" size="small" type="inner">
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="6"><a-form-item label="平台标识"><a-input v-model:value="editor.slug" /></a-form-item></a-col>
<a-col :span="6"><a-form-item label="显示名称"><a-input v-model:value="editor.displayName" /></a-form-item></a-col>
<a-col :span="4"><a-form-item label="状态">
<a-select v-model:value="editor.status">
<a-select-option value="draft">draft</a-select-option>
<a-select-option value="active">active</a-select-option>
<a-select-option value="disabled">disabled</a-select-option>
</a-select>
</a-form-item></a-col>
<a-col :span="4"><a-form-item label="课程步骤">
<a-select v-model:value="editor.courseQueryStepKey" allow-clear placeholder="不指定">
<a-select-option v-for="sk in courseQueryStepOptions" :key="sk" :value="sk">{{ sk }}</a-select-option>
</a-select>
</a-form-item></a-col>
<a-col :span="4"><a-form-item label="挑战超时"><a-input-number v-model:value="editor.challengeTimeoutSeconds" :min="30" /></a-form-item></a-col>
<a-col :span="24"><a-form-item label="描述"><a-textarea v-model:value="editor.description" :rows="2" /></a-form-item></a-col>
<a-col :span="24">
<a-space>
<a-checkbox v-model:checked="editor.enableBrowserChallenge">允许浏览器挑战</a-checkbox>
<a-checkbox v-model:checked="editor.supportsCatalog">章节目录</a-checkbox>
<a-checkbox v-model:checked="editor.supportsUnits">资源读取</a-checkbox>
<a-checkbox v-model:checked="editor.supportsProgress">进度聚合</a-checkbox>
</a-space>
</a-col>
</a-row>
</a-form>
<!-- Fields -->
<a-divider orientation="left">动态字段</a-divider>
<a-space style="margin-bottom: 12px;">
<a-button size="small" @click="editor.fields.push(createField('login'))"><PlusOutlined /> 登录字段</a-button>
<a-button size="small" @click="editor.fields.push(createField('course_query'))"><PlusOutlined /> 课程字段</a-button>
</a-space>
<a-row v-for="(f, i) in editor.fields" :key="`f-${i}`" :gutter="8" style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px dashed #f0f0f0;">
<a-col :span="3"><a-select v-model:value="f.scope" size="small"><a-select-option value="login">login</a-select-option><a-select-option value="course_query">course_query</a-select-option></a-select></a-col>
<a-col :span="3"><a-input v-model:value="f.key" size="small" placeholder="键名" /></a-col>
<a-col :span="3"><a-input v-model:value="f.label" size="small" placeholder="标签" /></a-col>
<a-col :span="3"><a-select v-model:value="f.type" size="small"><a-select-option value="text">text</a-select-option><a-select-option value="password">password</a-select-option><a-select-option value="number">number</a-select-option><a-select-option value="select">select</a-select-option><a-select-option value="textarea">textarea</a-select-option><a-select-option value="captcha_text">captcha_text</a-select-option><a-select-option value="hidden">hidden</a-select-option></a-select></a-col>
<a-col :span="2"><a-input-number v-model:value="f.displayOrder" :min="1" size="small" /></a-col>
<a-col :span="3"><a-input v-model:value="f.defaultValue" size="small" placeholder="默认值" /></a-col>
<a-col :span="4"><a-textarea v-model:value="f.optionsText" size="small" :rows="1" placeholder="Options JSON" /></a-col>
<a-col :span="3">
<a-checkbox v-model:checked="f.isRequired">必填</a-checkbox>
<a-checkbox v-model:checked="f.isSensitive">敏感</a-checkbox>
<a-button type="text" danger size="small" @click="editor.fields.splice(i, 1)"></a-button>
</a-col>
</a-row>
<!-- Steps -->
<a-divider orientation="left">工作流步骤</a-divider>
<a-space style="margin-bottom: 12px;">
<a-button size="small" @click="editor.steps.push(createStep('login'))"><PlusOutlined /> 登录</a-button>
<a-button size="small" @click="editor.steps.push(createStep('course_query'))"><PlusOutlined /> 课程</a-button>
<a-button size="small" @click="editor.steps.push(createStep('catalog'))"><PlusOutlined /> 目录</a-button>
<a-button size="small" @click="editor.steps.push(createStep('units'))"><PlusOutlined /> 资源</a-button>
<a-button size="small" @click="editor.steps.push(createStep('progress'))"><PlusOutlined /> 进度</a-button>
</a-space>
<a-row v-for="(s, i) in editor.steps" :key="`s-${i}`" :gutter="8" style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px dashed #f0f0f0;">
<a-col :span="2"><a-select v-model:value="s.scope" size="small"><a-select-option value="login">login</a-select-option><a-select-option value="course_query">course_query</a-select-option><a-select-option value="catalog">catalog</a-select-option><a-select-option value="units">units</a-select-option><a-select-option value="progress">progress</a-select-option></a-select></a-col>
<a-col :span="2"><a-input v-model:value="s.stepKey" size="small" placeholder="键名" /></a-col>
<a-col :span="2"><a-input v-model:value="s.displayName" size="small" placeholder="名称" /></a-col>
<a-col :span="2"><a-select v-model:value="s.stepType" size="small"><a-select-option value="http_request">http</a-select-option><a-select-option value="session_passthrough">passthrough</a-select-option><a-select-option value="browser_challenge">browser</a-select-option></a-select></a-col>
<a-col :span="2"><a-input v-model:value="s.httpMethod" size="small" /></a-col>
<a-col :span="2"><a-input-number v-model:value="s.displayOrder" :min="1" size="small" /></a-col>
<a-col :span="6"><a-input v-model:value="s.urlTemplate" size="small" placeholder="URL 模板" /></a-col>
<a-col :span="3">
<a-checkbox v-model:checked="s.isEnabled">启用</a-checkbox>
<a-button type="text" danger size="small" @click="editor.steps.splice(i, 1)"></a-button>
</a-col>
<a-col :span="6"><a-textarea v-model:value="s.queryTemplateJson" size="small" :rows="1" placeholder="Query JSON" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.headersTemplateJson" size="small" :rows="1" placeholder="Headers JSON" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.bodyTemplateJson" size="small" :rows="1" placeholder="Body JSON" /></a-col>
<a-col :span="6"><a-input v-model:value="s.successPath" size="small" placeholder="Success Path" /></a-col>
<a-col :span="6"><a-input v-model:value="s.successExpectedValue" size="small" placeholder="Expected" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.outputCookiesText" size="small" :rows="1" placeholder="Output Cookies" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.outputVariablesText" size="small" :rows="1" placeholder="Output Vars" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.courseOptionMappingText" size="small" :rows="1" placeholder="课程映射" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.catalogMappingText" size="small" :rows="1" placeholder="章节映射" /></a-col>
<a-col :span="6"><a-textarea v-model:value="s.unitMappingText" size="small" :rows="1" placeholder="资源映射" /></a-col>
</a-row>
<a-divider />
<a-button type="primary" :loading="saving" @click="submit" block>
{{ saving ? '保存中...' : editingPlatformId ? '保存平台配置' : '创建平台配置' }}
</a-button>
</a-card>
</a-card>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { getSystemSettings, updateSystemSettings } from '../../lib/api';
import type { UpdateSystemSettingRequest } from '../../types/api';
const loading = ref(false);
const saving = ref(false);
const form = reactive<UpdateSystemSettingRequest>({
systemName: 'UOOC Progress',
registrationMode: 'open',
allowMockFallback: false,
browserChallengeTimeoutSeconds: 600,
connectionEncryptionVersion: 1,
defaultPlatformVisibility: 'active_only',
requireEmailVerification: false,
smtpHost: null,
smtpPort: 587,
smtpUseSsl: true,
smtpUsername: null,
smtpPassword: null,
smtpFromEmail: null,
});
onMounted(async () => {
loading.value = true;
try {
const s = await getSystemSettings();
form.systemName = s.systemName;
form.registrationMode = s.registrationMode;
form.allowMockFallback = s.allowMockFallback;
form.browserChallengeTimeoutSeconds = s.browserChallengeTimeoutSeconds;
form.connectionEncryptionVersion = s.connectionEncryptionVersion;
form.defaultPlatformVisibility = s.defaultPlatformVisibility;
form.requireEmailVerification = s.requireEmailVerification;
form.smtpHost = s.smtpHost;
form.smtpPort = s.smtpPort;
form.smtpUseSsl = s.smtpUseSsl;
form.smtpUsername = s.smtpUsername;
form.smtpFromEmail = s.smtpFromEmail;
form.smtpPassword = null; // never pre-fill from server
} catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
finally { loading.value = false; }
});
async function submit() {
saving.value = true;
try {
await updateSystemSettings({ ...form });
message.success('已保存。');
form.smtpPassword = null;
} catch (err) { message.error(err instanceof Error ? err.message : '保存失败。'); }
finally { saving.value = false; }
}
</script>
<template>
<a-card title="系统设置" :loading="loading">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 12 }" style="max-width: 720px;">
<!-- System name -->
<a-form-item label="系统名称">
<a-input v-model:value="form.systemName" placeholder="UOOC Progress" />
</a-form-item>
<a-divider orientation="left" plain>注册</a-divider>
<a-form-item label="注册模式">
<a-select v-model:value="form.registrationMode">
<a-select-option value="open">开放注册</a-select-option>
<a-select-option value="invite_only">仅邀请码注册</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="默认平台可见性">
<a-select v-model:value="form.defaultPlatformVisibility">
<a-select-option value="active_only">仅显示启用的平台</a-select-option>
<a-select-option value="all">显示全部平台</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Mock 数据回退">
<a-switch v-model:checked="form.allowMockFallback" />
<span style="margin-left: 8px;">上游接口异常时回退到 mock 数据</span>
</a-form-item>
<a-divider orientation="left" plain>邮箱验证</a-divider>
<a-form-item label="启用邮箱验证">
<a-switch v-model:checked="form.requireEmailVerification" />
</a-form-item>
<template v-if="form.requireEmailVerification">
<a-form-item label="SMTP 服务器" required>
<a-input v-model:value="form.smtpHost" placeholder="例如 smtp.qq.com" />
</a-form-item>
<a-form-item label="端口">
<a-input-number v-model:value="form.smtpPort" :min="1" :max="65535" />
</a-form-item>
<a-form-item label="使用 SSL">
<a-switch v-model:checked="form.smtpUseSsl" />
</a-form-item>
<a-form-item label="发件邮箱" required>
<a-input v-model:value="form.smtpFromEmail" placeholder="例如 noreply@example.com" />
</a-form-item>
<a-form-item label="SMTP 账号">
<a-input v-model:value="form.smtpUsername" placeholder="留空则无需认证" />
</a-form-item>
<a-form-item label="SMTP 密码">
<a-input-password v-model:value="form.smtpPassword" placeholder="留空则不修改" />
</a-form-item>
</template>
<a-divider orientation="left" plain>其他</a-divider>
<a-form-item label="挑战超时秒数">
<a-input-number v-model:value="form.browserChallengeTimeoutSeconds" :min="30" />
</a-form-item>
<a-form-item label="连接加密版本">
<a-input-number v-model:value="form.connectionEncryptionVersion" :min="1" />
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6 }">
<a-button type="primary" :loading="saving" @click="submit">{{ saving ? '保存中...' : '保存设置' }}</a-button>
</a-form-item>
</a-form>
</a-card>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
getAdminBrushTasks, adminStopBrushTask, adminStopAllBrushTasks,
getAdminBrushConfig, updateAdminBrushConfig,
} from '../../lib/api';
import type { AdminBrushTaskDto } from '../../lib/api';
const tasks = ref<AdminBrushTaskDto[]>([]);
const pauseNew = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => { await refresh(); timer = setInterval(refresh, 3000); });
onUnmounted(() => { if (timer) clearInterval(timer); });
async function refresh() {
try { tasks.value = await getAdminBrushTasks(); } catch { /* ignore */ }
try { const c = await getAdminBrushConfig(); pauseNew.value = c.pauseNewTasks; } catch { /* ignore */ }
}
async function stopOne(userId: number) {
try { await adminStopBrushTask(userId); message.success('已停止。'); }
catch (err) { message.error(err instanceof Error ? err.message : '失败。'); }
}
async function stopAll() {
try { await adminStopAllBrushTasks(); message.success('已停止全部任务。'); }
catch (err) { message.error(err instanceof Error ? err.message : '失败。'); }
}
async function togglePause() {
pauseNew.value = !pauseNew.value;
try { await updateAdminBrushConfig({ pauseNewTasks: pauseNew.value }); message.success(pauseNew.value ? '已暂停接收新任务。' : '已恢复接收新任务。'); }
catch (err) { message.error(err instanceof Error ? err.message : '失败。'); pauseNew.value = !pauseNew.value; }
}
const columns = [
{ title: '用户ID', dataIndex: 'userId', width: 80 },
{ title: '状态', key: 'status', width: 80 },
{ title: '进度', key: 'progress', width: 100 },
{ title: '章节', dataIndex: 'currentChapterName', ellipsis: true },
{ title: '小节', dataIndex: 'currentSectionName', ellipsis: true },
{ title: '资源', dataIndex: 'currentVideoTitle', ellipsis: true },
{ title: '视频位置', key: 'pos', width: 100 },
{ title: '错误', dataIndex: 'lastError', ellipsis: true, width: 150 },
{ title: '操作', key: 'action', width: 80 },
];
</script>
<template>
<a-card title="任务监控">
<template #extra>
<a-space>
<a-button @click="togglePause" :type="pauseNew ? 'primary' : 'default'" danger>
{{ pauseNew ? '已暂停新任务' : '暂停新任务' }}
</a-button>
<a-button danger @click="stopAll" :disabled="tasks.length === 0">停止全部</a-button>
<a-button @click="refresh">刷新</a-button>
</a-space>
</template>
<a-empty v-if="tasks.length === 0" description="暂无运行中的刷课任务。" style="padding: 24px 0;" />
<a-table v-else :data-source="tasks" :columns="columns" row-key="userId" size="small" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'Running' ? 'processing' : record.status === 'Completed' ? 'success' : record.status === 'Failed' ? 'error' : 'default'">
{{ record.status === 'Running' ? '进行中' : record.status === 'Completed' ? '完成' : record.status === 'Failed' ? '失败' : record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'progress'">
{{ record.completedVideos }}/{{ record.totalVideos }}
</template>
<template v-else-if="column.key === 'pos'">
{{ Math.round(record.currentVideoPos) }}s / {{ Math.round(record.currentVideoLength) }}s
<a-tag v-if="record.retryCount > 0" :color="record.retryCount >= 3 ? 'error' : 'warning'" style="margin-left: 4px;">
{{ record.retryCount }}/3
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button size="small" danger :disabled="record.status !== 'Running'" @click="stopOne(record.userId)">停止</a-button>
</template>
</template>
</a-table>
</a-card>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { getAdminUsers, updateAdminUser } from '../../lib/api';
import type { AuthUserDto } from '../../types/api';
const users = ref<AuthUserDto[]>([]);
const loading = ref(false);
const savingId = ref<number | null>(null);
async function load() {
loading.value = true;
try { users.value = await getAdminUsers(); }
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
finally { loading.value = false; }
}
async function save(u: AuthUserDto) {
savingId.value = u.id;
try {
const updated = await updateAdminUser(u.id, { displayName: u.displayName, role: u.role, status: u.status });
users.value = users.value.map(x => x.id === updated.id ? updated : x);
message.success(`已更新 ${updated.displayName}`);
} catch (err) { message.error(err instanceof Error ? err.message : '更新失败。'); }
finally { savingId.value = null; }
}
onMounted(load);
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
{ title: '显示名', dataIndex: 'displayName', key: 'displayName' },
{ title: '角色', dataIndex: 'role', key: 'role' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '创建时间', key: 'createdAt' },
{ title: '最近登录', key: 'lastLoginAt' },
{ title: '操作', key: 'action', width: 100 },
];
</script>
<template>
<a-card title="用户管理">
<template #extra>
<a-button @click="load" :loading="loading">刷新</a-button>
</template>
<a-table :data-source="users" :columns="columns" :loading="loading" row-key="id" size="small" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'displayName'">
<a-input v-model:value="record.displayName" size="small" style="width: 120px;" />
</template>
<template v-else-if="column.key === 'role'">
<a-select v-model:value="record.role" size="small" style="width: 100px;">
<a-select-option value="user">普通用户</a-select-option>
<a-select-option value="admin">管理员</a-select-option>
</a-select>
</template>
<template v-else-if="column.key === 'status'">
<a-select v-model:value="record.status" size="small" style="width: 80px;">
<a-select-option value="active">启用</a-select-option>
<a-select-option value="disabled">停用</a-select-option>
</a-select>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ new Date(record.createdAt).toLocaleString('zh-CN') }}
</template>
<template v-else-if="column.key === 'lastLoginAt'">
{{ record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="primary" size="small" :loading="savingId === record.id" @click="save(record)">
{{ savingId === record.id ? '...' : '保存' }}
</a-button>
</template>
</template>
</a-table>
</a-card>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { useAuthStore } from '../../stores/auth';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const form = ref({ username: '', password: '' });
const loading = ref(false);
async function submit() {
if (!form.value.username.trim() || !form.value.password.trim()) {
message.error('请输入用户名和密码。');
return;
}
loading.value = true;
try {
await authStore.login(form.value.username, form.value.password);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/courses';
await router.replace(redirect);
} finally {
loading.value = false;
}
}
</script>
<template>
<a-layout style="min-height: 100vh; background: #f0f2f5;">
<a-layout-content style="display: flex; align-items: center; justify-content: center; padding: 16px;">
<div style="width: 100%; max-width: 400px;">
<a-flex vertical align="center" style="margin-bottom: 32px;">
<a-typography-title :level="2" style="margin-bottom: 4px;">{{ authStore.systemName }}</a-typography-title>
<a-typography-text type="secondary">学习进度管理平台</a-typography-text>
</a-flex>
<a-card>
<a-typography-title :level="4" style="margin-bottom: 24px; text-align: center;">
系统登录
</a-typography-title>
<a-alert
v-if="authStore.authError"
type="error"
:message="authStore.authError"
closable
style="margin-bottom: 24px;"
/>
<a-form :model="form" @finish="submit">
<a-form-item name="username" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input
v-model:value="form.username"
size="large"
placeholder="用户名"
autocomplete="username"
>
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item name="password" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password
v-model:value="form.password"
size="large"
placeholder="密码"
autocomplete="current-password"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" block size="large" :loading="loading">
登录
</a-button>
</a-form-item>
</a-form>
<a-flex justify="center">
<RouterLink to="/register">
<a-button type="link">还没有账号去注册</a-button>
</RouterLink>
</a-flex>
</a-card>
</div>
</a-layout-content>
</a-layout>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { MailOutlined, UserOutlined, SmileOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { sendEmailCode } from '../../lib/api';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const form = ref({ username: '', displayName: '', password: '', inviteCode: '', email: '', emailCode: '' });
const loading = ref(false);
const sendingCode = ref(false);
const needInviteCode = computed(() => authStore.registrationMode === 'invite_only');
async function handleSendCode() {
if (!form.value.email.trim()) { message.warning('请先输入邮箱。'); return; }
sendingCode.value = true;
try {
await sendEmailCode(form.value.email.trim());
message.success('验证码已发送。');
} catch (err) {
message.error(err instanceof Error ? err.message : '发送失败。');
} finally { sendingCode.value = false; }
}
async function submit() {
loading.value = true;
try {
await authStore.register({
username: form.value.username.trim(),
displayName: form.value.displayName.trim(),
password: form.value.password,
inviteCode: form.value.inviteCode.trim() || undefined,
email: form.value.email.trim() || undefined,
emailCode: form.value.emailCode.trim() || undefined,
});
await router.replace('/courses');
} finally {
loading.value = false;
}
}
</script>
<template>
<a-layout style="min-height: 100vh; background: #f0f2f5;">
<a-layout-content style="display: flex; align-items: center; justify-content: center; padding: 16px;">
<div style="width: 100%; max-width: 440px;">
<a-flex vertical align="center" style="margin-bottom: 32px;">
<a-typography-title :level="2" style="margin-bottom: 4px;">{{ authStore.systemName }}</a-typography-title>
<a-typography-text type="secondary">创建新账号</a-typography-text>
</a-flex>
<a-card>
<a-typography-title :level="4" style="margin-bottom: 24px; text-align: center;">
注册账号
</a-typography-title>
<a-alert
v-if="authStore.authError"
type="error"
:message="authStore.authError"
closable
style="margin-bottom: 24px;"
/>
<a-form :model="form" @finish="submit">
<a-form-item name="username" :rules="[{ required: true, min: 3, message: '用户名至少 3 位' }]">
<a-input v-model:value="form.username" size="large" placeholder="用户名">
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item name="displayName" :rules="[{ required: true, message: '请输入显示名' }]">
<a-input v-model:value="form.displayName" size="large" placeholder="显示名">
<template #prefix><SmileOutlined /></template>
</a-input>
</a-form-item>
<a-form-item name="password" :rules="[{ required: true, min: 6, message: '密码至少 6 位' }]">
<a-input-password v-model:value="form.password" size="large" placeholder="密码(至少 6 位)">
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<!-- Email + verification code -->
<a-form-item
v-if="authStore.requireEmailVerification"
name="email"
:rules="[{ required: authStore.requireEmailVerification, type: 'email', message: '请输入有效邮箱' }]"
>
<a-input v-model:value="form.email" size="large" placeholder="邮箱地址">
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<a-form-item v-if="authStore.requireEmailVerification" name="emailCode" :rules="[{ required: true, message: '请输入验证码' }]">
<a-input-search
v-model:value="form.emailCode"
size="large"
placeholder="邮箱验证码"
:loading="sendingCode"
@search="handleSendCode"
>
<template #enterButton>发送验证码</template>
</a-input-search>
</a-form-item>
<a-form-item name="inviteCode" :rules="needInviteCode ? [{ required: true, message: '当前注册模式需要邀请码' }] : []">
<a-input v-model:value="form.inviteCode" size="large" placeholder="邀请码(选填)" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" block size="large" :loading="loading">
注册并进入后台
</a-button>
</a-form-item>
</a-form>
<a-flex justify="center">
<RouterLink to="/login">
<a-button type="link">已有账号去登录</a-button>
</RouterLink>
</a-flex>
</a-card>
</div>
</a-layout-content>
</a-layout>
</template>
+23
View File
@@ -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"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -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,
},
});
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<script>
window.AliyunCaptchaConfig = {
region: "cn",
prefix: "dq1y8x"
};
</script>
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"></script>
<style>
#button {
width: 200px;
height: 40px;
background: #1677ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
</style>
</head>
<body>
<!-- 验证码挂载点 -->
<div id="captcha-element"></div>
<!-- 你自己的按钮 -->
<button id="button">
点击验证
</button>
<script>
let captcha;
window.initAliyunCaptcha({
SceneId: "137o3jmc",
mode: "popup",
element: "#captcha-element",
button: "#button",
success: function (captchaVerifyParam) {
console.log("验证成功");
console.log(captchaVerifyParam);
},
fail: function (result) {
console.log("失败");
console.log(result);
},
getInstance: function (instance) {
captcha = instance;
},
slideStyle: {
width: 360,
height: 40,
}
});
</script>
</body>
</html>
+306
View File
@@ -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
<script src="https://cstaticdun.126.net/load.min.js"></script>
<script>
initNECaptcha({
element: '#captcha',
captchaId: '75f9f716460a422f89a628f50fd8cc2b',
mode: 'popup',
onVerify: function(err, data) {
// data.validate 就是需要的 token
}
}, onload, onerror);
</script>
```
---
## 三、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