using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Api.Security; using Tiku.Application.Catalog; using Tiku.Application.Learning; using Tiku.Application.Scoreline; using Tiku.Application.Security; namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-分数线")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [Produces("application/json")] [Route("api/student/scoreline")] public sealed class ScorelineController( IScorelineQueryService scorelineQueryService, ILearningAccessService learningAccessService, LearningActorResolver learningActorResolver) : ControllerBase { private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."]; [HttpGet("fields")] [EndpointSummary("查询分数线字段配置")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetFields( [FromQuery] ScorelineQueryDto query, CancellationToken cancellationToken) { return Ok(await scorelineQueryService.GetFieldsAsync( query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), cancellationToken)); } [HttpGet("records")] [EndpointSummary("分页查询分数线记录")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetRecords( [FromQuery] ScorelineQueryDto query, CancellationToken cancellationToken) { return Ok(await scorelineQueryService.GetRecordsAsync( query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()), cancellationToken)); } [HttpGet("records/cursor")] [EndpointSummary("游标分页查询分数线记录")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> GetRecordsCursor( [FromQuery] ScorelineQueryDto query, CancellationToken cancellationToken) { return Ok(await scorelineQueryService.GetRecordsCursorAsync( query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()), query.Cursor, cancellationToken)); } [HttpGet("trend")] [EndpointSummary("查询历年分数线趋势")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetTrend( [FromQuery] ScorelineQueryDto query, CancellationToken cancellationToken) { return Ok(await scorelineQueryService.GetTrendAsync( query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()), cancellationToken)); } [HttpGet("years")] [EndpointSummary("查询分数线可用年份")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetYears( [FromQuery] ScorelineQueryDto query, CancellationToken cancellationToken) { return Ok(await scorelineQueryService.GetYearsAsync( query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), cancellationToken)); } private IReadOnlyCollection ResolveDynamicFilters() { var filters = new List(); foreach (var (key, value) in Request.Query) { var prefix = DynamicPrefixes.FirstOrDefault(key.StartsWith); if (prefix is null) continue; var raw = value.FirstOrDefault(); if (string.IsNullOrWhiteSpace(raw)) continue; filters.Add(new ScorelineDynamicFilter( prefix.TrimEnd('.'), key[prefix.Length..], raw.Trim())); } return filters; } private async Task ResolveTenantIdAsync( ScorelineQueryDto query, CancellationToken cancellationToken) { var actor = learningActorResolver.Resolve(); var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken); if (query.RegionId.HasValue && query.RegionId != snapshot.TargetRegionId && !snapshot.LicensedRegionIds.Contains(query.RegionId.Value)) throw new LearningAccessException( "scoreline_region_not_entitled", "The requested scoreline region is outside the current learning grant."); query.RegionId ??= snapshot.TargetRegionId ?? (snapshot.LicensedRegionIds.Count == 1 ? snapshot.LicensedRegionIds.Single() : null); return actor.TenantId; } }