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:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
+ $"§ion_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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user