diff --git a/WEBSITE-COMPETITION-BACKEND.md b/WEBSITE-COMPETITION-BACKEND.md index 3d5de81..515da4c 100644 --- a/WEBSITE-COMPETITION-BACKEND.md +++ b/WEBSITE-COMPETITION-BACKEND.md @@ -6,7 +6,9 @@ 正式环境访问基地址为 `https://jitsi-web.yuedongsports.com/`,管理接口完整地址为 `https://jitsi-web.yuedongsports.com/api/website-competition`。此地址是后台对外入口;`CompetitionApi:AdminUrl` 是后端转发到官网赛事服务的地址,需单独配置。 -`YD_Event.Admin` 在服务端把请求转发到官网赛事服务的内部 `/api/competition/admin`。两台服务器通过私网连接,不使用服务间管理密钥。官网赛事服务按来源 IP 放行内部管理请求,持久化草稿或发布状态,并通知公开页面刷新。本仓库不新增赛事数据库表。 +`YD_Event.Admin` 在服务端把请求转发到官网赛事服务的内部 `/api/competition/admin`。两台服务器通过私网连接,不使用服务间管理密钥。官网赛事服务按来源 IP 放行内部管理请求,持久化草稿或发布状态,供公开页面读取。本仓库不新增赛事数据库表。 + +官网公开页面不访问需要登录的管理接口,而是匿名请求 `GET /api/competition/state`。此路由由 `CompetitionPublicController` 通过同一私网赛事服务读取 `/api/competition/state`;赛事服务在未发布时只返回空态。公开接口只支持读取,不提供草稿或写入能力。官网每 10 秒重新读取一次。 ```text YD_Event.Web 管理页 @@ -22,8 +24,9 @@ YD_Event.Web 管理页 | --- | --- | --- | | `GET` | `/api/website-competition` | 读取当前环境的完整赛事草稿,包括未发布内容 | | `PUT` | `/api/website-competition` | 保存赛事信息及 `published` 状态,JSON 请求体上限为 8 MB | +| `GET` | `/api/competition/state` | 匿名读取已发布数据;未发布时返回空态 | -两个方法都要求已有的后台登录态。控制器原样转发上游的 JSON 响应和 HTTP 状态码。数据的主要结构如下;详细字段由管理页 Excel 模板及官网赛事服务校验规则约束。 +前两个管理方法要求已有的后台登录态;公开方法不要求登录。控制器原样转发上游的 JSON 响应和 HTTP 状态码。数据的主要结构如下;详细字段由管理页 Excel 模板及官网赛事服务校验规则约束。 ```json { @@ -45,12 +48,15 @@ YD_Event.Web 管理页 正式环境两台服务器分机部署,必须将 `CompetitionApi__AdminUrl` 配置为 `http://<赛事服务私网 IP>:8787/api/competition/admin`,不能使用默认的 `127.0.0.1`。赛事服务应只在私网地址监听,并通过 `COMPETITION_ADMIN_ALLOWED_IPS` 放行 `YD_Event.Admin` 的私网 IP;防火墙只允许后台服务器连接该端口。公网 Nginx 必须屏蔽 `/api/competition/admin`,避免绕过后台登录认证直接修改赛事数据。 +公开状态接口从 `CompetitionApi__AdminUrl` 推导同源的 `/api/competition/state`,因此不需要额外的地址或密钥配置。正式环境沿用现有 `/api/` 反向代理到 YD_Event.Admin;不要把公开接口直接转发到赛事服务。 + ## 部署步骤 1. 启动官网赛事服务,设置持久化数据路径、私网监听地址、后台服务器的来源 IP 白名单;其健康检查为 `GET /api/competition/ping`。 2. 在 `YD_Event.Admin` 的运行环境配置内网管理接口 URL,然后启动或重启后端。`Startup` 已注册专用 `WebsiteCompetition` HttpClient。 3. 将后台站点同源的 `/api/website-competition` 路由转发到 `YD_Event.Admin`。不要把该路径直接暴露或转发到官网赛事服务管理接口。 4. 管理员登录 `YD_Event.Web` 后打开“官网赛事信息”,导入 `.xlsx`、核对草稿并保存;需要公开展示时再开启“公开发布”。 +5. 从 `https://www.yuedongsports.com` 携带 `Origin` 请求正式环境的 `GET /api/competition/state`,确认返回已发布数据或空态,以及允许官网来源的 CORS 响应头。 ## 错误排查 @@ -71,3 +77,5 @@ dotnet build .\YD_Event.Web.Entry\YD_Event.Web.Entry.csproj --no-restore ``` 确认官网赛事服务的 `/api/competition/ping` 返回成功;使用已有后台登录态访问 `GET /api/website-competition`,应返回上述赛事结构。未经登录直接访问应被后台认证拒绝。`PUT` 应先在测试环境使用草稿数据验证,再按发布流程操作。 + +匿名访问 `GET /api/competition/state` 应返回公开空态或已发布数据。若返回 `404`,检查新控制器是否已部署,以及 Nginx 是否仍使用直接代理赛事服务的旧片段;若返回 `502`,检查私网连接及 `CompetitionApi__AdminUrl`。 diff --git a/YD_Event.Web.Entry/Controllers/CompetitionPublicController.cs b/YD_Event.Web.Entry/Controllers/CompetitionPublicController.cs new file mode 100644 index 0000000..86ea046 --- /dev/null +++ b/YD_Event.Web.Entry/Controllers/CompetitionPublicController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace YD_Event.Web.Entry.Controllers; + +/// +/// 官网赛事公开数据。只转发赛事服务的已发布状态,不提供草稿或写入接口。 +/// +[ApiController] +[AllowAnonymous] +[Route("api/competition")] +public sealed class CompetitionPublicController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; + + public CompetitionPublicController(IConfiguration configuration, IHttpClientFactory httpClientFactory) + { + _configuration = configuration; + _httpClientFactory = httpClientFactory; + } + + [HttpGet("state")] + public async Task GetState() + { + var adminUrl = _configuration["CompetitionApi:AdminUrl"] + ?? "http://127.0.0.1:8787/api/competition/admin"; + if (!Uri.TryCreate(adminUrl, UriKind.Absolute, out var adminTarget) + || (adminTarget.Scheme != Uri.UriSchemeHttp && adminTarget.Scheme != Uri.UriSchemeHttps) + || adminTarget.AbsolutePath != "/api/competition/admin" + || !string.IsNullOrEmpty(adminTarget.Query)) + return StatusCode(503, new { error = "官网赛事接口地址无效" }); + + var target = new Uri(adminTarget, "state"); + + Response.Headers["Cache-Control"] = "no-store"; + try + { + var client = _httpClientFactory.CreateClient("WebsiteCompetition"); + client.Timeout = TimeSpan.FromSeconds(15); + using var upstreamResponse = await client.GetAsync(target, 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 = "官网赛事接口响应超时" }); + } + } +}