feat(website): expose published competition state

Add an anonymous read-only state endpoint for the public website, backed by the private competition service. Keep draft reads and writes behind existing admin login and document the production routing.
This commit is contained in:
ltx 2026-10-20 09:15:33 +08:00
parent 4ad36addea
commit 2e00719d56
2 changed files with 69 additions and 2 deletions

View File

@ -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`。

View File

@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace YD_Event.Web.Entry.Controllers;
/// <summary>
/// 官网赛事公开数据。只转发赛事服务的已发布状态,不提供草稿或写入接口。
/// </summary>
[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<IActionResult> 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 = "官网赛事接口响应超时" });
}
}
}