From 98aeb67212fd6229d64c1a376edf80de11f75123 Mon Sep 17 00:00:00 2001 From: ltx <1006158920@qq.com> Date: Mon, 19 Oct 2026 16:08:59 +0800 Subject: [PATCH] feat(website): add authenticated competition management proxy Forward authenticated GET and PUT requests to the website competition service using a server-side token. Register a dedicated HttpClient and document configuration, deployment, API behavior, and validation. --- WEBSITE-COMPETITION-BACKEND.md | 74 +++++++++++++++ YD_Event.Web.Core/Startup.cs | 1 + .../WebsiteCompetitionController.cs | 90 +++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 WEBSITE-COMPETITION-BACKEND.md create mode 100644 YD_Event.Web.Entry/Controllers/WebsiteCompetitionController.cs diff --git a/WEBSITE-COMPETITION-BACKEND.md b/WEBSITE-COMPETITION-BACKEND.md new file mode 100644 index 0000000..e0feea6 --- /dev/null +++ b/WEBSITE-COMPETITION-BACKEND.md @@ -0,0 +1,74 @@ +# 官网赛事信息后端接口 + +## 功能与调用链 + +`YD_Event.Web` 的“官网赛事信息”页面请求当前环境的 `GET/PUT /api/website-competition`。此接口由 `YD_Event.Admin` 提供,沿用后台登录认证;管理页不需要填写接口地址或管理密钥。 + +`YD_Event.Admin` 在服务端把请求转发到官网赛事服务的 `/api/competition/admin`。服务间密钥只保存在服务器配置中,不发送给浏览器。官网赛事服务负责校验数据、持久化草稿或发布状态,并通知公开页面刷新。本仓库不新增赛事数据库表。 + +```text +YD_Event.Web 管理页 + → 当前域名 /api/website-competition(后台登录态) + → YD_Event.Admin(附加服务间 Bearer 密钥) + → 官网赛事服务 /api/competition/admin + → 持久化赛事 JSON / 官网公开接口 +``` + +## 接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/website-competition` | 读取当前环境的完整赛事草稿,包括未发布内容 | +| `PUT` | `/api/website-competition` | 保存赛事信息及 `published` 状态,JSON 请求体上限为 8 MB | + +两个方法都要求已有的后台登录态。控制器原样转发上游的 JSON 响应和 HTTP 状态码。数据的主要结构如下;详细字段由管理页 Excel 模板及官网赛事服务校验规则约束。 + +```json +{ + "published": false, + "competition": { "nameZh": "", "nameEn": "", "dateZh": "", "dateEn": "", "venueZh": "", "venueEn": "" }, + "notice": { "enabled": false, "textZh": "", "textEn": "" }, + "schedule": { "currentId": "", "callingId": "", "upcomingCount": 3, "items": [] }, + "results": { "overall": [], "challenge": [], "battle": [] }, + "players": [], + "records": [] +} +``` + +## 服务端配置 + +| 配置键 | 环境变量写法 | 说明 | +| --- | --- | --- | +| `CompetitionApi:AdminUrl` | `CompetitionApi__AdminUrl` | 官网赛事服务管理接口完整 URL;默认 `http://127.0.0.1:8787/api/competition/admin` | +| `CompetitionApi:AdminToken` | `CompetitionApi__AdminToken` | 与官网赛事服务 `COMPETITION_ADMIN_TOKEN` 相同的服务间密钥 | +| `CompetitionApi:AdminTokenFile` | `CompetitionApi__AdminTokenFile` | 可选,密钥文件的绝对路径;未配置密钥值时读取 | +| `COMPETITION_ADMIN_TOKEN` | `COMPETITION_ADMIN_TOKEN` | 密钥值的兼容性回退配置 | + +官网赛事服务要求 `COMPETITION_ADMIN_TOKEN` 至少 32 字符。不要将密钥写入前端环境变量、前端代码或公开仓库。若两个服务不在同一网络命名空间,必须配置可由 `YD_Event.Admin` 访问的 `CompetitionApi__AdminUrl`,不能使用默认的 `127.0.0.1`。 + +## 部署步骤 + +1. 启动官网赛事服务,设置持久化数据路径及 `COMPETITION_ADMIN_TOKEN`;其健康检查为 `GET /api/competition/ping`。 +2. 在 `YD_Event.Admin` 的运行环境配置管理接口 URL 与相同的服务间密钥,然后启动或重启后端。`Startup` 已注册专用 `WebsiteCompetition` HttpClient。 +3. 将后台站点同源的 `/api/website-competition` 路由转发到 `YD_Event.Admin`。不要把该路径直接暴露或转发到官网赛事服务管理接口。 +4. 管理员登录 `YD_Event.Web` 后打开“官网赛事信息”,导入 `.xlsx`、核对草稿并保存;需要公开展示时再开启“公开发布”。 + +## 错误排查 + +| 状态 | 常见原因 | +| --- | --- | +| `401/403` | 后台未登录或无权访问;若由上游返回,也可能是服务间密钥不一致 | +| `413` | `PUT` 请求体超过 8 MB | +| `502` | 官网赛事服务无法连接 | +| `503` | 服务端密钥缺失、密钥文件不可读取,或上游 URL 无效 | +| `504` | 官网赛事服务超过 30 秒未响应 | + +控制器不会在错误响应或日志中输出密钥。保存时上游的字段校验错误会原样返回,管理页会展示错误信息。 + +## 验证 + +```powershell +dotnet build .\YD_Event.Web.Entry\YD_Event.Web.Entry.csproj --no-restore +``` + +确认官网赛事服务的 `/api/competition/ping` 返回成功;使用已有后台登录态访问 `GET /api/website-competition`,应返回上述赛事结构。未经登录直接访问应被后台认证拒绝。`PUT` 应先在测试环境使用草稿数据验证,再按发布流程操作。 diff --git a/YD_Event.Web.Core/Startup.cs b/YD_Event.Web.Core/Startup.cs index 6b59773..044508e 100644 --- a/YD_Event.Web.Core/Startup.cs +++ b/YD_Event.Web.Core/Startup.cs @@ -106,6 +106,7 @@ public class Startup : AppStartup //远程请求 文档:https://furion.net/docs/http-agent services.AddHttpRemote(); + services.AddHttpClient("WebsiteCompetition"); services.AddControllers() .AddNewtonsoftJson(options => diff --git a/YD_Event.Web.Entry/Controllers/WebsiteCompetitionController.cs b/YD_Event.Web.Entry/Controllers/WebsiteCompetitionController.cs new file mode 100644 index 0000000..e0af363 --- /dev/null +++ b/YD_Event.Web.Entry/Controllers/WebsiteCompetitionController.cs @@ -0,0 +1,90 @@ +using System.Net.Http.Headers; +using System.Text; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace YD_Event.Web.Entry.Controllers; + +/// +/// 官网赛事信息管理。使用后台登录态访问,服务间密钥只保存在服务端。 +/// +[ApiController] +[Authorize] +[Route("api/website-competition")] +public sealed class WebsiteCompetitionController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; + + public WebsiteCompetitionController(IConfiguration configuration, IHttpClientFactory httpClientFactory) + { + _configuration = configuration; + _httpClientFactory = httpClientFactory; + } + + [HttpGet] + public Task Get() => ForwardAsync(HttpMethod.Get); + + [HttpPut] + [RequestSizeLimit(8 * 1024 * 1024)] + public Task Put() => ForwardAsync(HttpMethod.Put); + + private async Task ForwardAsync(HttpMethod method) + { + var token = _configuration["CompetitionApi:AdminToken"] + ?? _configuration["COMPETITION_ADMIN_TOKEN"]; + var tokenFile = _configuration["CompetitionApi:AdminTokenFile"]; + if (string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(tokenFile)) + { + try + { + token = (await System.IO.File.ReadAllTextAsync(tokenFile, HttpContext.RequestAborted)).Trim(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return StatusCode(503, new { error = "官网赛事接口密钥文件不可读取" }); + } + } + + if (string.IsNullOrWhiteSpace(token)) + return StatusCode(503, new { error = "官网赛事接口未配置服务端密钥" }); + + var adminUrl = _configuration["CompetitionApi:AdminUrl"] + ?? "http://127.0.0.1:8787/api/competition/admin"; + if (!Uri.TryCreate(adminUrl, UriKind.Absolute, out var target) + || (target.Scheme != Uri.UriSchemeHttp && target.Scheme != Uri.UriSchemeHttps)) + return StatusCode(503, new { error = "官网赛事接口地址无效" }); + + using var upstreamRequest = new HttpRequestMessage(method, target); + upstreamRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (method == HttpMethod.Put) + { + using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(HttpContext.RequestAborted); + upstreamRequest.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + try + { + var client = _httpClientFactory.CreateClient("WebsiteCompetition"); + client.Timeout = TimeSpan.FromSeconds(30); + using var upstreamResponse = await client.SendAsync( + upstreamRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); + var response = await upstreamResponse.Content.ReadAsStringAsync(HttpContext.RequestAborted); + return new ContentResult + { + Content = response, + ContentType = "application/json; charset=utf-8", + StatusCode = (int)upstreamResponse.StatusCode + }; + } + catch (HttpRequestException) + { + return StatusCode(502, new { error = "官网赛事接口连接失败" }); + } + catch (TaskCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) + { + return StatusCode(504, new { error = "官网赛事接口响应超时" }); + } + } +}