feat: добавил интеграционные тесты
This commit is contained in:
@@ -5,31 +5,87 @@ using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление определениями достижений системы геймификации.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/achievements")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class AchievementsController : ControllerBase
|
||||
{
|
||||
private readonly IAchievementService _achievements;
|
||||
|
||||
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
|
||||
|
||||
/// <summary>Получить список всех достижений.</summary>
|
||||
/// <remarks>Возвращает определения достижений (без информации о получении конкретным пользователем).
|
||||
/// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.</remarks>
|
||||
/// <response code="200">Список достижений.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<AchievementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
|
||||
|
||||
/// <summary>Получить достижение по ID.</summary>
|
||||
/// <param name="id">ID достижения.</param>
|
||||
/// <response code="200">Данные достижения.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Достижение не найдено.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AchievementDto>> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Создать новое достижение.</summary>
|
||||
/// <remarks>Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.</remarks>
|
||||
/// <param name="req">Название, описание, иконка, награда в XP/монетах и условие получения.</param>
|
||||
/// <response code="201">Достижение создано.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить достижение по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID достижения.</param>
|
||||
/// <param name="req">Обновляемые поля достижения.</param>
|
||||
/// <response code="200">Обновлённые данные достижения.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Достижение не найдено.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
|
||||
Ok(await _achievements.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить достижение по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID достижения.</param>
|
||||
/// <response code="204">Достижение удалено.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Достижение не найдено.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _achievements.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,36 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Аутентификация и управление сессией пользователя.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
[Produces("application/json")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _auth;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
private const string MicrosoftStateCookieName = "msAuthState";
|
||||
private const string MicrosoftStateCookieName = "msAuthState";
|
||||
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
|
||||
|
||||
public AuthController(IAuthService auth, IConfiguration config)
|
||||
{
|
||||
_auth = auth;
|
||||
_auth = auth;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
|
||||
/// <remarks>
|
||||
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
|
||||
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
|
||||
/// refresh token устанавливается в HttpOnly cookie.
|
||||
/// </remarks>
|
||||
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
|
||||
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
|
||||
/// <response code="400">Неверный или просроченный authorization code.</response>
|
||||
[HttpPost("login/microsoft")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
||||
{
|
||||
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
|
||||
@@ -32,10 +45,19 @@ public class AuthController : ControllerBase
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
// Server-driven auth flow: frontend just navigates here; backend builds Microsoft authorize URL.
|
||||
// Optional returnUrl is stored in a short-lived cookie and used by callback.
|
||||
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
|
||||
/// <remarks>
|
||||
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
|
||||
/// и редиректит пользователя на `login.microsoftonline.com`.
|
||||
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
|
||||
/// </remarks>
|
||||
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
|
||||
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
|
||||
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
|
||||
[HttpGet("login/microsoft")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
|
||||
{
|
||||
var tenantId = _config["AzureAd:TenantId"];
|
||||
@@ -51,9 +73,9 @@ public class AuthController : ControllerBase
|
||||
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
|
||||
@@ -61,9 +83,9 @@ public class AuthController : ControllerBase
|
||||
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,19 +94,37 @@ public class AuthController : ControllerBase
|
||||
|
||||
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["client_id"] = clientId,
|
||||
["response_type"] = "code",
|
||||
["redirect_uri"] = redirectUri,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_mode"] = "query",
|
||||
["scope"] = scope,
|
||||
["state"] = state
|
||||
["scope"] = scope,
|
||||
["state"] = state
|
||||
});
|
||||
|
||||
return Redirect(authorizeUrl);
|
||||
}
|
||||
|
||||
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
|
||||
/// <remarks>
|
||||
/// Microsoft редиректит браузер сюда после успешного входа.
|
||||
/// Backend валидирует CSRF state, обменивает code на токены,
|
||||
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
|
||||
/// </remarks>
|
||||
/// <param name="code">Authorization code от Microsoft.</param>
|
||||
/// <param name="state">CSRF state для верификации.</param>
|
||||
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
|
||||
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
|
||||
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
|
||||
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
|
||||
/// <response code="400">Отсутствует authorization code.</response>
|
||||
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
|
||||
[HttpGet("callback/microsoft")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> CallbackMicrosoft(
|
||||
[FromQuery] string? code = null,
|
||||
[FromQuery] string? state = null,
|
||||
@@ -129,7 +169,17 @@ public class AuthController : ControllerBase
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
|
||||
/// <remarks>
|
||||
/// Создаёт или находит пользователя по email без реального OAuth flow.
|
||||
/// Возвращает 404 в Production и Staging.
|
||||
/// </remarks>
|
||||
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
|
||||
/// <response code="200">Успешный вход.</response>
|
||||
/// <response code="404">Endpoint недоступен вне Development.</response>
|
||||
[HttpPost("login/dev")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
||||
{
|
||||
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
||||
@@ -139,7 +189,16 @@ public class AuthController : ControllerBase
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
|
||||
/// <remarks>
|
||||
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
|
||||
/// Возвращает новую пару токенов и обновляет cookie.
|
||||
/// </remarks>
|
||||
/// <response code="200">Новая пара токенов.</response>
|
||||
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
|
||||
[HttpPost("refresh")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<AuthResponse>> Refresh()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
@@ -149,8 +208,17 @@ public class AuthController : ControllerBase
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Выход из системы — отзыв refresh token.</summary>
|
||||
/// <remarks>
|
||||
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
|
||||
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
|
||||
/// </remarks>
|
||||
/// <response code="204">Выход выполнен успешно.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
@@ -160,8 +228,15 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
|
||||
/// <response code="200">Данные текущего пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
||||
[Authorize]
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Me()
|
||||
{
|
||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
@@ -175,7 +250,7 @@ public class AuthController : ControllerBase
|
||||
Response.Cookies.Append("refreshToken", token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
|
||||
Expires = DateTime.UtcNow.AddDays(30)
|
||||
Expires = DateTime.UtcNow.AddDays(30)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,132 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Courses;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление курсами (дисциплинами) и их тегами.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/courses")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class CoursesController : ControllerBase
|
||||
{
|
||||
private readonly ICourseService _courses;
|
||||
|
||||
public CoursesController(ICourseService courses) => _courses = courses;
|
||||
|
||||
/// <summary>Получить список курсов с фильтрацией и пагинацией.</summary>
|
||||
/// <param name="filter">Фильтры: tagId, search, isSynced; параметры пагинации.</param>
|
||||
/// <response code="200">Список курсов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<CourseDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
|
||||
Ok(await _courses.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Получить курс по ID (включая теги).</summary>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <response code="200">Данные курса с тегами.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Курс не найден.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CourseDto>> Get(int id) => Ok(await _courses.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Создать новый курс.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="req">Название и описание курса.</param>
|
||||
/// <response code="201">Курс создан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить курс по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <param name="req">Новое название и/или описание.</param>
|
||||
/// <response code="200">Обновлённые данные курса.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
|
||||
Ok(await _courses.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить курс по ID.</summary>
|
||||
/// <remarks>Только Admin. Удаление курса каскадно удаляет связанные лекции.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <response code="204">Курс удалён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _courses.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Привязать тег к курсу.</summary>
|
||||
/// <remarks>Только Admin. Тег должен существовать в системе.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <param name="tagId">ID тега.</param>
|
||||
/// <response code="204">Тег привязан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс или тег не найден.</response>
|
||||
/// <response code="409">Тег уже привязан к курсу.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("{id:int}/tags")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> AddTag(int id, [FromBody] int tagId)
|
||||
{ await _courses.AddTagAsync(id, tagId); return NoContent(); }
|
||||
{
|
||||
await _courses.AddTagAsync(id, tagId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отвязать тег от курса.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <param name="tagId">ID тега.</param>
|
||||
/// <response code="204">Тег отвязан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс или тег не найден, либо связь не существует.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}/tags/{tagId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> RemoveTag(int id, int tagId)
|
||||
{ await _courses.RemoveTagAsync(id, tagId); return NoContent(); }
|
||||
{
|
||||
await _courses.RemoveTagAsync(id, tagId);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,59 +7,197 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Каталог лекций — просмотр, управление, запись и отзывы.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/lectures")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class LecturesController : ControllerBase
|
||||
{
|
||||
private readonly ILectureService _lectures;
|
||||
private readonly IReviewService _reviews;
|
||||
public LecturesController(ILectureService lectures, IReviewService reviews)
|
||||
{ _lectures = lectures; _reviews = reviews; }
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
private readonly IReviewService _reviews;
|
||||
|
||||
public LecturesController(ILectureService lectures, IReviewService reviews)
|
||||
{
|
||||
_lectures = lectures;
|
||||
_reviews = reviews;
|
||||
}
|
||||
|
||||
private int CurrentUserId => int.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
|
||||
/// <param name="filter">
|
||||
/// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
|
||||
/// isOpen, tagId, search; параметры пагинации.
|
||||
/// </param>
|
||||
/// <response code="200">Список лекций (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<LectureDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
|
||||
Ok(await _lectures.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Получить детальную карточку лекции по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="200">Детальные данные лекции.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Get(int id) =>
|
||||
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
|
||||
|
||||
/// <summary>Создать новую лекцию.</summary>
|
||||
/// <remarks>Только Admin. Курс задаётся при создании и не может быть изменён.</remarks>
|
||||
/// <param name="req">Данные лекции: курс, преподаватель, локация, время, формат, вместимость.</param>
|
||||
/// <response code="201">Лекция создана.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить лекцию по ID.</summary>
|
||||
/// <remarks>Admin или Teacher. CourseId изменить нельзя.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="req">Обновляемые поля: преподаватель, локация, время, формат, описание.</param>
|
||||
/// <response code="200">Обновлённые данные лекции.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
|
||||
Ok(await _lectures.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить лекцию по ID.</summary>
|
||||
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="204">Лекция удалена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _lectures.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Записаться на лекцию.</summary>
|
||||
/// <remarks>
|
||||
/// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.
|
||||
/// После посещения начисляются монеты через gamification.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="204">Запись выполнена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Student.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
/// <response code="409">Студент уже записан или мест нет.</response>
|
||||
[Authorize(Roles = "Student")]
|
||||
[HttpPost("{id:int}/enroll")]
|
||||
public async Task<IActionResult> Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Enroll(int id)
|
||||
{
|
||||
await _lectures.EnrollAsync(id, CurrentUserId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отменить запись на лекцию.</summary>
|
||||
/// <remarks>Только Student. Отменить можно только свою запись.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="204">Запись отменена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Student.</response>
|
||||
/// <response code="404">Лекция или запись не найдена.</response>
|
||||
[Authorize(Roles = "Student")]
|
||||
[HttpDelete("{id:int}/enroll")]
|
||||
public async Task<IActionResult> Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Unenroll(int id)
|
||||
{
|
||||
await _lectures.UnenrollAsync(id, CurrentUserId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отметить посещение студента на лекции.</summary>
|
||||
/// <remarks>
|
||||
/// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение
|
||||
/// через gamification service.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="userId">ID студента.</param>
|
||||
/// <param name="attended">true — посетил, false — не посетил.</param>
|
||||
/// <response code="204">Посещение отмечено.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция или запись студента не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpPatch("{id:int}/attendance/{userId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
|
||||
{ await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); }
|
||||
{
|
||||
await _lectures.MarkAttendanceAsync(id, userId, attended);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Получить список записавшихся студентов на лекцию.</summary>
|
||||
/// <remarks>Только Admin или Teacher. Включает флаг посещения (`attended`).</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список записей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpGet("{id:int}/enrollments")]
|
||||
[ProducesResponseType(typeof(PagedResult<EnrollmentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
|
||||
|
||||
/// <summary>Получить отзывы к лекции.</summary>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[HttpGet("{id:int}/reviews")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetByLectureAsync(id, pagination));
|
||||
|
||||
}
|
||||
|
||||
@@ -5,31 +5,85 @@ using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление локациями проведения лекций (аудитории, онлайн-площадки).</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/locations")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class LocationsController : ControllerBase
|
||||
{
|
||||
private readonly ILocationService _locations;
|
||||
|
||||
public LocationsController(ILocationService locations) => _locations = locations;
|
||||
|
||||
/// <summary>Получить список всех локаций.</summary>
|
||||
/// <response code="200">Список локаций.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<LocationDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
|
||||
|
||||
/// <summary>Получить локацию по ID.</summary>
|
||||
/// <param name="id">ID локации.</param>
|
||||
/// <response code="200">Данные локации.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Локация не найдена.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LocationDto>> Get(int id) => Ok(await _locations.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Создать новую локацию.</summary>
|
||||
/// <remarks>Только Admin. Локации также создаются автоматически при синхронизации с Modeus.</remarks>
|
||||
/// <param name="req">Название, корпус, аудитория и/или адрес.</param>
|
||||
/// <response code="201">Локация создана.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить локацию по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID локации.</param>
|
||||
/// <param name="req">Обновляемые поля: название, корпус, аудитория, адрес.</param>
|
||||
/// <response code="200">Обновлённые данные локации.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Локация не найдена.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
|
||||
Ok(await _locations.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить локацию по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID локации.</param>
|
||||
/// <response code="204">Локация удалена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Локация не найдена.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _locations.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,40 +7,123 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Отзывы студентов на лекции с LLM-анализом и модерацией.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/reviews")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class ReviewsController : ControllerBase
|
||||
{
|
||||
private readonly IReviewService _reviews;
|
||||
public ReviewsController(IReviewService reviews) => _reviews = reviews;
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
public ReviewsController(IReviewService reviews) => _reviews = reviews;
|
||||
|
||||
private int CurrentUserId => int.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
/// <summary>Создать отзыв к лекции.</summary>
|
||||
/// <remarks>
|
||||
/// Только Student. После создания отзыв помещается в очередь LLM-анализа
|
||||
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
|
||||
/// скрытно от пользователя.
|
||||
/// </remarks>
|
||||
/// <param name="req">ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.</param>
|
||||
/// <response code="201">Отзыв создан и поставлен в очередь на LLM-анализ.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Student.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
/// <response code="409">Студент уже оставил отзыв к этой лекции.</response>
|
||||
[Authorize(Roles = "Student")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
|
||||
|
||||
/// <summary>Получить отзыв по ID.</summary>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="200">Данные отзыва (включая LLM-статус и сентимент).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ReviewDto>> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Обновить отзыв.</summary>
|
||||
/// <remarks>
|
||||
/// Разрешено любому авторизованному пользователю, но сервис проверяет владельца.
|
||||
/// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).
|
||||
/// </remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <param name="req">Новая оценка и/или текст.</param>
|
||||
/// <response code="200">Обновлённые данные отзыва.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Отзыв принадлежит другому пользователю.</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
|
||||
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
|
||||
|
||||
/// <summary>Удалить отзыв.</summary>
|
||||
/// <remarks>Владелец может удалить свой отзыв. Admin может удалить любой.</remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="204">Отзыв удалён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Нет прав на удаление (не владелец и не Admin).</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Получить список отзывов, ожидающих LLM-анализа.</summary>
|
||||
/// <remarks>Только Admin. Используется для мониторинга очереди обработки.</remarks>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов со статусом Pending (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("pending")]
|
||||
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetPendingAsync(pagination));
|
||||
|
||||
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его
|
||||
/// в очередь на повторную обработку фоновым сервисом.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="204">Повторный анализ запланирован.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("{id:int}/reanalyze")]
|
||||
public async Task<IActionResult> Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Reanalyze(int id)
|
||||
{
|
||||
await _reviews.ReanalyzeAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,74 @@ using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Синхронизация данных из внешней системы расписания Modeus (только Admin).</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/sync")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Produces("application/json")]
|
||||
public class SyncController : ControllerBase
|
||||
{
|
||||
private readonly IScheduleSyncService _sync;
|
||||
|
||||
public SyncController(IScheduleSyncService sync) => _sync = sync;
|
||||
|
||||
/// <summary>Запустить синхронизацию расписания лекций из Modeus.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
|
||||
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности,
|
||||
/// периоду и типу занятий.
|
||||
/// </remarks>
|
||||
/// <param name="req">Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId.</param>
|
||||
/// <response code="200">Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpPost("schedule")]
|
||||
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
|
||||
Ok(await _sync.SyncScheduleAsync(req));
|
||||
|
||||
/// <summary>Получить статус последней синхронизации.</summary>
|
||||
/// <remarks>Только Admin. Возвращает время и результат последней успешной синхронизации.</remarks>
|
||||
/// <response code="200">Статус синхронизации.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpGet("status")]
|
||||
[ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SyncStatusDto>> Status() =>
|
||||
Ok(await _sync.GetLastSyncStatusAsync());
|
||||
|
||||
/// <summary>Синхронизировать аудитории (локации) из Modeus.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт
|
||||
/// соответствующие записи в таблице locations.
|
||||
/// </remarks>
|
||||
/// <response code="200">Результат синхронизации аудиторий.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpPost("rooms")]
|
||||
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
|
||||
Ok(await _sync.SyncRoomsAsync());
|
||||
|
||||
/// <summary>Поиск преподавателей в Modeus по ФИО.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Ищет преподавателей через внешнее API и возвращает список
|
||||
/// для ручного импорта. Найденные преподаватели не создаются автоматически.
|
||||
/// </remarks>
|
||||
/// <param name="fullname">Полное имя или часть имени преподавателя для поиска.</param>
|
||||
/// <response code="200">Список найденных преподавателей.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpPost("employees")]
|
||||
[ProducesResponseType(typeof(List<EmployeeDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
|
||||
Ok(await _sync.SearchEmployeesAsync(fullname));
|
||||
|
||||
}
|
||||
|
||||
@@ -6,35 +6,101 @@ using UniVerse.Domain.Enums;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление тегами для категоризации курсов (институты, факультеты, темы и др.).</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/tags")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class TagsController : ControllerBase
|
||||
{
|
||||
private readonly ITagService _tags;
|
||||
|
||||
public TagsController(ITagService tags) => _tags = tags;
|
||||
|
||||
/// <summary>Получить список тегов с опциональной фильтрацией по типу и родителю.</summary>
|
||||
/// <param name="type">Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.</param>
|
||||
/// <param name="parentId">ID родительского тега (фильтрация дочерних).</param>
|
||||
/// <response code="200">Список тегов.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<TagDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
|
||||
Ok(await _tags.GetAllAsync(type, parentId));
|
||||
|
||||
/// <summary>Получить тег по ID.</summary>
|
||||
/// <param name="id">ID тега.</param>
|
||||
/// <response code="200">Данные тега.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Тег не найден.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TagDto>> Get(int id) => Ok(await _tags.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Получить иерархическое дерево всех тегов.</summary>
|
||||
/// <remarks>
|
||||
/// Возвращает корневые теги с вложенными дочерними тегами.
|
||||
/// Полезно для построения фильтрующих UI-компонентов.
|
||||
/// </remarks>
|
||||
/// <response code="200">Иерархический список тегов.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("tree")]
|
||||
[ProducesResponseType(typeof(List<TagTreeDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
|
||||
|
||||
/// <summary>Создать новый тег.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="req">Название, тип и опциональный родительский тег.</param>
|
||||
/// <response code="201">Тег создан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить тег по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID тега.</param>
|
||||
/// <param name="req">Новое название, тип и/или родительский тег.</param>
|
||||
/// <response code="200">Обновлённые данные тега.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Тег не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
|
||||
Ok(await _tags.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить тег по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).
|
||||
/// Дочерние теги остаются, но их `parentId` становится null.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID тега.</param>
|
||||
/// <response code="204">Тег удалён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Тег не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _tags.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,34 +8,76 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление пользователями, профилями и геймификацией.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _users;
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IUserService _users;
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IGamificationService _gamification;
|
||||
|
||||
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
|
||||
{
|
||||
_users = users; _reviews = reviews; _gamification = gamification;
|
||||
}
|
||||
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
/// <summary>Получить профиль пользователя по ID.</summary>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <response code="200">Данные пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserDto>> Get(int id) => Ok(await _users.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Обновить профиль пользователя (displayName, avatarUrl).</summary>
|
||||
/// <remarks>Разрешено только самому пользователю или Admin.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="req">Обновляемые поля профиля.</param>
|
||||
/// <response code="200">Обновлённые данные пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Нет прав — только владелец профиля или Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
|
||||
{
|
||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||
return Ok(await _users.UpdateProfileAsync(id, req));
|
||||
}
|
||||
|
||||
/// <summary>Получить статистику пользователя (XP, монеты, уровень, посещения).</summary>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <response code="200">Статистика пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpGet("{id:int}/stats")]
|
||||
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||
|
||||
/// <summary>Получить список записей пользователя на лекции.</summary>
|
||||
/// <remarks>Разрешено только самому пользователю или Admin.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список записей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Нет прав — только владелец или Admin.</response>
|
||||
[HttpGet("{id:int}/enrollments")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
|
||||
{
|
||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||
@@ -43,36 +85,92 @@ public class UsersController : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Получить отзывы пользователя.</summary>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("{id:int}/reviews")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetByUserAsync(id, pagination));
|
||||
|
||||
/// <summary>Получить достижения пользователя.</summary>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <response code="200">Список полученных достижений.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("{id:int}/achievements")]
|
||||
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> Achievements(int id) =>
|
||||
Ok(await _gamification.GetUserAchievementsAsync(id));
|
||||
|
||||
/// <summary>Получить историю транзакций монет пользователя.</summary>
|
||||
/// <remarks>Разрешено только самому пользователю или Admin.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">История транзакций (пагинированная).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Нет прав — только владелец или Admin.</response>
|
||||
[HttpGet("{id:int}/transactions")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
|
||||
{
|
||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
|
||||
}
|
||||
|
||||
/// <summary>Получить список всех пользователей с фильтрацией и пагинацией.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="filter">Параметры фильтрации (поиск, роль, активность) и пагинации.</param>
|
||||
/// <response code="200">Список пользователей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<UserDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
|
||||
Ok(await _users.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Изменить роль пользователя.</summary>
|
||||
/// <remarks>Только Admin. Доступные роли: Student, Teacher, Admin.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="role">Новая роль.</param>
|
||||
/// <response code="204">Роль успешно изменена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPatch("{id:int}/role")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
|
||||
{
|
||||
await _users.SetRoleAsync(id, role);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Активировать или деактивировать аккаунт пользователя.</summary>
|
||||
/// <remarks>Только Admin. Деактивированный пользователь не может войти в систему.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="isActive">true — активировать, false — деактивировать.</param>
|
||||
/// <response code="204">Статус успешно изменён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPatch("{id:int}/active")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
|
||||
{
|
||||
await _users.SetActiveAsync(id, isActive);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace UniVerse.Api.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Swagger operation filter that:
|
||||
/// 1. Adds Bearer security requirement only to endpoints that actually require authentication.
|
||||
/// 2. Appends a "Required roles: ..." remark to the operation description when role restrictions exist.
|
||||
///
|
||||
/// This replaces the global AddSecurityRequirement approach so anonymous endpoints
|
||||
/// (auth/login, auth/refresh, auth/callback) don't show the lock icon in Swagger UI.
|
||||
/// </summary>
|
||||
public class AuthorizeOperationFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
// Collect [Authorize] and [AllowAnonymous] from both the controller and the action.
|
||||
var actionAttributes = context.MethodInfo.GetCustomAttributes(inherit: true);
|
||||
var controllerAttributes = context.MethodInfo.DeclaringType?
|
||||
.GetCustomAttributes(inherit: true) ?? [];
|
||||
|
||||
var allAttributes = actionAttributes.Concat(controllerAttributes).ToList();
|
||||
|
||||
var hasAllowAnonymous = allAttributes.OfType<AllowAnonymousAttribute>().Any();
|
||||
if (hasAllowAnonymous)
|
||||
return; // completely public — no lock icon
|
||||
|
||||
var authorizeAttributes = allAttributes.OfType<AuthorizeAttribute>().ToList();
|
||||
if (authorizeAttributes.Count == 0)
|
||||
return; // no [Authorize] at all — also public
|
||||
|
||||
// Collect all distinct roles across all [Authorize(Roles = "...")] attributes.
|
||||
var roles = authorizeAttributes
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Roles))
|
||||
.SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(r => r)
|
||||
.ToList();
|
||||
|
||||
// Append role information to the operation description.
|
||||
var roleInfo = roles.Count > 0
|
||||
? $"**Required roles:** {string.Join(", ", roles)}"
|
||||
: "**Required:** any authenticated user";
|
||||
|
||||
operation.Description = string.IsNullOrWhiteSpace(operation.Description)
|
||||
? roleInfo
|
||||
: $"{operation.Description}\n\n{roleInfo}";
|
||||
|
||||
operation.Responses ??= new OpenApiResponses();
|
||||
|
||||
// Add 401 / 403 responses if not already declared.
|
||||
if (!operation.Responses.ContainsKey("401"))
|
||||
{
|
||||
operation.Responses.Add("401", new OpenApiResponse
|
||||
{
|
||||
Description = "Unauthorized — JWT token missing or invalid"
|
||||
});
|
||||
}
|
||||
|
||||
if (roles.Count > 0 && !operation.Responses.ContainsKey("403"))
|
||||
{
|
||||
operation.Responses.Add("403", new OpenApiResponse
|
||||
{
|
||||
Description = $"Forbidden — requires role: {string.Join(" or ", roles)}"
|
||||
});
|
||||
}
|
||||
|
||||
// Add Bearer security requirement to this specific operation.
|
||||
// OpenAPI v2 (Microsoft.OpenApi 2.x) uses OpenApiSecuritySchemeReference
|
||||
// instead of OpenApiSecurityScheme with a Reference property.
|
||||
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", context.Document);
|
||||
|
||||
operation.Security ??= [];
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[bearerSchemeRef] = []
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using Serilog;
|
||||
using UniVerse.Api.BackgroundServices;
|
||||
using UniVerse.Api.Filters;
|
||||
using UniVerse.Api.Middleware;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
@@ -119,29 +120,36 @@ builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "UniVerse API",
|
||||
Version = "v1",
|
||||
Description = "Universe"
|
||||
Title = "UniVerse API",
|
||||
Version = "v1",
|
||||
Description =
|
||||
"REST API веб-платформы UniVerse.\n\n" +
|
||||
"Аутентификация: JWT Bearer (получить через `POST /api/v1/auth/login/microsoft` или `POST /api/v1/auth/login/dev` в Development).",
|
||||
Contact = new OpenApiContact
|
||||
{
|
||||
Name = "UniVerse Dev"
|
||||
}
|
||||
});
|
||||
|
||||
// Bearer security scheme definition (used per-endpoint by AuthorizeOperationFilter)
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "bearer",
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Enter your JWT token"
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
|
||||
});
|
||||
|
||||
options.AddSecurityRequirement(doc =>
|
||||
{
|
||||
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
|
||||
return new OpenApiSecurityRequirement
|
||||
{
|
||||
[bearerSchemeRef] = new List<string>()
|
||||
};
|
||||
});
|
||||
// Include XML doc comments generated from controller /// summaries
|
||||
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
if (File.Exists(xmlPath))
|
||||
options.IncludeXmlComments(xmlPath);
|
||||
|
||||
// Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
|
||||
options.OperationFilter<AuthorizeOperationFilter>();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<RootNamespace>UniVerse.Api</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- Suppress warnings for public members without XML docs -->
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user