using Dpz.Core.EnumLibrary;
namespace Dpz.Core.Web.Controllers;
public class SteamController(ISteamGameService steamGameService) : Controller
{
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
this.SetTitle("我的Steam游戏");
var games = await steamGameService.GetGameSummariesAsync(cancellationToken);
return View(games);
}
public async Task<IActionResult> Achievements(
int? id = null,
CancellationToken cancellationToken = default
)
{
if (id == null)
{
return NotFound();
}
var model = await steamGameService.GetGameAsync(id.Value, cancellationToken);
if (model == null)
{
return NotFound();
}
var pageMetaPage = new VmPageMetadata
{
Description =
"游戏成就:" + string.Join(",", model.Achievements.Select(x => x.DisplayName)),
Keywords = [$"{model.Name},{model.Name}游戏成就"],
Relations = ["Steam", "Achievements", id.Value.ToString()],
};
ViewData["PageMetadata"] = pageMetaPage;
this.SetTitle($"{model.Name} 成就");
return View(model);
}
[HttpGet, CheckAuthorize(Permissions = Permissions.System)]
public async Task<IActionResult> ClearCache(CancellationToken cancellationToken)
{
await steamGameService.ClearCacheAsync(cancellationToken);
return RedirectToAction(nameof(Index));
}
}
文件定位
SteamController.cs 是 MVC 页面控制器(继承 Controller,返回 View),位于 src/Dpz.Core.Web/Controllers/ 目录下,属于"我的 Steam 游戏库"这一展示型站点功能。它与 WebApi 侧的 SteamController 同名但职责不同:WebApi 那个是 [ApiController]/ControllerBase,返回 JSON;本文件返回 Razor 视图。
依赖注入采用 C# 主构造函数写法:SteamController(ISteamGameService steamGameService),即只依赖一个查询服务。命名空间以外的 using 只有 Dpz.Core.EnumLibrary,其余符号(Controller、ISteamGameService、VmPageMetadata、SetTitle 等)来自 Web 项目的 Usings.cs 全局 using。
三个 Action 的职责
1. Index(游戏库列表)
this.SetTitle("我的Steam游戏");
var games = await steamGameService.GetGameSummariesAsync(cancellationToken);
return View(games);
SetTitle是 WebToolsExtensions.cs 中定义的Controller扩展方法,写入ViewData["Title"] = "{title} - 叫我阿胖"。- 数据由 ISteamGameService.GetGameSummariesAsync 提供,返回
List<SteamGameSummaryResponse>,对应视图 Views/Steam/Index.cshtml 的@model IList<...SteamGameSummaryResponse>。 - 实现 SteamGameService.cs 里该方法是投影查询(只取成就总数、已解锁数、最近解锁少量成就),并带
[Cache](1 天)缓存;列表中每张卡片通过 AchievementSummary.cshtml 局部视图渲染成就摘要,并链接到Achievements。
2. Achievements(int? id)(单游戏成就详情)
处理流程:
id == null→NotFound()。- 调
steamGameService.GetGameAsync(id.Value, ...);返回null→NotFound()(服务实现中找不到时会记LogWarning)。 - 组装
VmPageMetadata(VmPageMetadata.cs)写入ViewData["PageMetadata"]:Description= "游戏成就:" + 所有成就DisplayName逗号拼接;Keywords=["{游戏名},{游戏名}游戏成就"];Relations=["Steam", "Achievements", id],即注释所说的{controller, action, id...}三元组(该类注释标明"仅用于兼容旧数据")。
SetTitle($"{model.Name} 成就"),返回View(model),模型为 SteamGameResponse,由 Views/Steam/Achievements.cshtml 渲染(区分已解锁/未解锁/隐藏成就)。
PageMetadata 并不是渲染时直接读取就完事:全局过滤器 GlobalFilter.cs 在 Action 执行后会调用 IPageMetadataService.ResolvePageMetadataAsync(route, explicitMetadata, ...),把 Action 里显式设置的 VmPageMetadata 与路由元数据、公共元数据合并后回写 ViewData["PageMetadata"],最终由 Views/Shared/_PageMetadataPartial.cshtml 输出 SEO 标签。同一个 GlobalFilter 还负责把 SteamController 的 ViewBag.Menu 设为 MenuItem.Steam(导航高亮),以及 X-PJAX 请求时把 ViewResult 转成 PartialViewResult 并把标题 Base64 写入响应头。
3. ClearCache(管理端缓存清理)
[HttpGet, CheckAuthorize(Permissions = Permissions.System)]
public async Task<IActionResult> ClearCache(CancellationToken cancellationToken)
{
await steamGameService.ClearCacheAsync(cancellationToken);
return RedirectToAction(nameof(Index));
}
CheckAuthorize(CheckAuthorizeAttribute.cs)是基于IFilterFactory的权限过滤器:它先检查[AllowAnonymous]跳过,再校验是否已登录、会话是否被撤销(IAccountSessionService)、用户身份、安全戳(IAccountService+IMediator的ValidateUserSecurityStampRequest)、账号是否启用、以及Permissions按位与是否包含所需位;失败时按是否期望 JSON 返回JsonResult或重定向到登录/Account.ReLogin,并把VmUserInfo塞进ViewData["CurrentUser"]。这里要求Permissions.System。- 真正"清缓存"的逻辑在服务实现里:
ClearCacheAsync本身只返回Task.CompletedTask,靠方法上的[InvalidateCache(Methods = [nameof(GetGamesAsync), nameof(GetGameSummariesAsync), nameof(GetGameAsync)])]特性使这三个被[Cache]标记的查询失效,属于 AOP/特性驱动的缓存失效。 - 执行完重定向回
Index,即用户看到刷新后的游戏库。
协作关系概览(含过滤器时序)
flowchart TD
A[浏览器请求 /Steam/...] --> B[GlobalFilter.OnActionExecuting: 设置 SessionId / ViewBag.Menu = MenuItem.Steam]
B --> C{目标 Action}
C -->|Index| D[SetTitle + GetGameSummariesAsync]
C -->|Achievements| E[CheckAuthorize? 无<br/>id 为空或模型为空 -> NotFound<br/>否则构造 VmPageMetadata + SetTitle]
C -->|ClearCache| F[CheckAuthorize: 登录/会话/安全戳/启用/Permissions.System]
F -->|不通过| G[重定向登录 / ReLogin / 403 / JsonResult]
F -->|通过| H[steamGameService.ClearCacheAsync -> InvalidateCache 3 个查询]
H --> I[RedirectToAction Index]
D --> J[View: Views/Steam/Index.cshtml]
E --> K[View: Views/Steam/Achievements.cshtml]
J --> L[GlobalFilter.OnActionExecuted: ResolvePageMetadataAsync 合并 SEO 元数据; PJAX 时转 PartialViewResult]
K --> L
L --> M[_PageMetadataPartial 输出 meta 标签]
分层上:控制器只做参数校验 + 视图模型/元数据装配;数据查询(含 MongoDB 投影、Mapster 映射、缓存)在 SteamGameService.cs,接口契约在 ISteamGameService.cs;游戏库与成就的同步由注释所述的 Dpz.Core.Service.Mediator 的 Steam 模块(如 SyncSteamGamesHandler.cs)和 SteamActivator.cs 定时任务负责,本控制器不参与写入。
需要留意的点
Achievements只判断了model == null,没有判空model.Achievements;视图里直接Model.Achievements.Count。该字段是否在SteamGameResponse上默认初始化为空集合,我未在本次工具调用中读取该文件确认,因此不做断言。ClearCache使用[HttpGet]执行有副作用的操作(使缓存失效),实际属于应走 POST 的场景,且未加防伪令牌;这是设计上的注意点而非 bug。id == null/model == null都返回NotFound(),而 GlobalFilter.cs 对NotFoundResult会提前return,跳过 SEO 元数据解析——行为一致。Achievements中Relations与Keywords的写法(Relations用于旧数据兼容)说明该页面的 SEO 归属既可能来自显式设置的VmPageMetadata,也可能来自数据库中的PageMetadata记录,最终结果由IPageMetadataService.ResolvePageMetadataAsync合并决定。