303 lines
9.8 KiB
C#
303 lines
9.8 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using MilliSim.Common.Dtos;
|
||
using MilliSim.Common.Models;
|
||
using MilliSim.Services;
|
||
|
||
namespace MilliSim.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/projects")]
|
||
public class ProjectsController : ControllerBase
|
||
{
|
||
private readonly IProjectService _projectService;
|
||
|
||
public ProjectsController(IProjectService projectService)
|
||
{
|
||
_projectService = projectService;
|
||
}
|
||
|
||
// ===== 前台接口 =====
|
||
|
||
/// <summary>
|
||
/// 公开项目列表
|
||
/// </summary>
|
||
[HttpGet("public")]
|
||
public async Task<ApiResponse<PagedResult<ProjectDto>>> GetPublicProjects([FromQuery] ProjectQueryRequest request)
|
||
{
|
||
return await _projectService.GetPublishedProjectsAsync(request);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 公开项目详情(自增浏览数)
|
||
/// </summary>
|
||
[HttpGet("public/{id:long}")]
|
||
public async Task<ApiResponse<ProjectDto>> GetPublicProject(long id)
|
||
{
|
||
return await _projectService.GetProjectByIdAsync(id, incrementView: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 相关项目
|
||
/// </summary>
|
||
[HttpGet("related/{id:long}")]
|
||
public async Task<ApiResponse<List<ProjectDto>>> GetRelatedProjects(long id, [FromQuery] int count = 4)
|
||
{
|
||
return await _projectService.GetRelatedProjectsAsync(id, count);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 收藏切换
|
||
/// </summary>
|
||
[HttpPost("{id:long}/favorite")]
|
||
[Authorize]
|
||
public async Task<ApiResponse> ToggleFavorite(long id)
|
||
{
|
||
return await _projectService.ToggleFavoriteAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 我的收藏
|
||
/// </summary>
|
||
[HttpGet("favorites")]
|
||
[Authorize]
|
||
public async Task<ApiResponse<PagedResult<ProjectDto>>> GetFavorites([FromQuery] PageRequest request)
|
||
{
|
||
return await _projectService.GetFavoritesAsync(request);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 记录体验
|
||
/// </summary>
|
||
[HttpPost("{id:long}/experience")]
|
||
[Authorize]
|
||
public async Task<ApiResponse> RecordExperience(long id, [FromBody] RecordExperienceRequest request)
|
||
{
|
||
return await _projectService.RecordExperienceAsync(id, request.DurationSeconds);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 体验记录
|
||
/// </summary>
|
||
[HttpGet("experience-logs")]
|
||
[Authorize]
|
||
public async Task<ApiResponse<PagedResult<ExperienceLogDto>>> GetExperienceLogs([FromQuery] PageRequest request)
|
||
{
|
||
return await _projectService.GetExperienceLogsAsync(request);
|
||
}
|
||
|
||
// ===== 后台接口 =====
|
||
|
||
/// <summary>
|
||
/// 全部项目列表
|
||
/// </summary>
|
||
[HttpGet]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse<PagedResult<ProjectDto>>> GetProjects([FromQuery] ProjectQueryRequest request)
|
||
{
|
||
return await _projectService.GetProjectsAsync(request);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 项目详情
|
||
/// </summary>
|
||
[HttpGet("{id:long}")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse<ProjectDto>> GetProject(long id)
|
||
{
|
||
return await _projectService.GetProjectByIdAsync(id, incrementView: false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建项目
|
||
/// </summary>
|
||
[HttpPost]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse<ProjectDto>> CreateProject([FromBody] CreateProjectRequest request)
|
||
{
|
||
return await _projectService.CreateProjectAsync(request);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新项目
|
||
/// </summary>
|
||
[HttpPost("{id:long}")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse<ProjectDto>> UpdateProject(long id, [FromBody] UpdateProjectRequest request)
|
||
{
|
||
return await _projectService.UpdateProjectAsync(id, request);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除项目
|
||
/// </summary>
|
||
[HttpPost("{id:long}/delete")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse> DeleteProject(long id)
|
||
{
|
||
return await _projectService.DeleteProjectAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 发布/下架切换
|
||
/// </summary>
|
||
[HttpPost("{id:long}/toggle-publish")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse> TogglePublish(long id)
|
||
{
|
||
return await _projectService.TogglePublishAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 推荐切换
|
||
/// </summary>
|
||
[HttpPost("{id:long}/toggle-featured")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse> ToggleFeatured(long id)
|
||
{
|
||
return await _projectService.ToggleFeaturedAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 资源包列表(公开接口,前台项目详情页需要显示运行按钮)
|
||
/// </summary>
|
||
[HttpGet("{id:long}/packages")]
|
||
public async Task<ApiResponse<List<PackageDto>>> GetPackages(long id)
|
||
{
|
||
return await _projectService.GetPackagesAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传资源包
|
||
/// </summary>
|
||
[HttpPost("{id:long}/packages")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse<PackageDto>> UploadPackage(long id, [FromForm] PackageType type, [FromForm] string? wakeProtocolPrefix, [FromForm] string? wakeProtocolParam, IFormFile file)
|
||
{
|
||
return await _projectService.UploadPackageAsync(id, type, file, wakeProtocolPrefix ?? string.Empty, wakeProtocolParam ?? string.Empty);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取 WebGL 在线运行入口 URL(公开接口,前台在线运行使用)
|
||
/// </summary>
|
||
[HttpGet("{id:long}/webgl-run-url")]
|
||
public async Task<ApiResponse<string>> GetWebGLRunUrl(long id)
|
||
{
|
||
return await _projectService.GetWebGLRunUrlAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// WebGL 在线运行 - 代理 index.html(公开接口)
|
||
/// 浏览器无法直接渲染 COS 私有桶的 index.html,通过后端代理读取并注入 base 标签
|
||
/// 资源文件通过 webgl-resource 端点 302 重定向到签名 URL 加载
|
||
/// </summary>
|
||
[HttpGet("{id:long}/webgl-index")]
|
||
public async Task<IActionResult> GetWebGLIndex(long id)
|
||
{
|
||
try
|
||
{
|
||
var html = await _projectService.GetWebGLIndexHtmlAsync(id);
|
||
return Content(html, "text/html", System.Text.Encoding.UTF8);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { message = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// WebGL 资源代理 - 302 重定向到签名 URL(公开接口)
|
||
/// index.html 中的 base 标签将相对路径指向此端点
|
||
/// 浏览器请求资源时,此端点生成签名 URL 并重定向,浏览器直接从 COS 加载
|
||
/// </summary>
|
||
[HttpGet("{id:long}/webgl-resource/{*path}")]
|
||
public async Task<IActionResult> GetWebGLResource(long id, string path)
|
||
{
|
||
try
|
||
{
|
||
var signedUrl = await _projectService.GetWebGLResourceUrlAsync(id, path);
|
||
return Redirect(signedUrl);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { message = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 视频代理 - 流式转发视频内容,不暴露 COS 签名直链(防盗链)
|
||
/// 前端 video src 指向此端点,后端流式代理 COS 文件
|
||
/// 支持 Range 请求(视频拖动进度条)
|
||
/// </summary>
|
||
[HttpGet("{id:long}/video")]
|
||
public async Task<IActionResult> GetVideo(long id)
|
||
{
|
||
try
|
||
{
|
||
var rangeHeader = Request.Headers.Range.ToString();
|
||
var (stream, contentType, fileSize, isPartial, contentRange) = await _projectService.GetVideoStreamAsync(id, rangeHeader);
|
||
// 防止浏览器缓存视频到磁盘
|
||
Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
|
||
Response.Headers.Pragma = "no-cache";
|
||
Response.Headers.Expires = "0";
|
||
// 内联播放,不触发下载
|
||
Response.Headers.ContentDisposition = "inline";
|
||
// 支持 Range 请求时设置 Content-Range 和 206 状态码
|
||
if (isPartial && !string.IsNullOrEmpty(contentRange))
|
||
{
|
||
Response.Headers.ContentRange = contentRange;
|
||
Response.StatusCode = 206;
|
||
}
|
||
if (fileSize.HasValue)
|
||
{
|
||
Response.Headers.ContentLength = fileSize.Value;
|
||
}
|
||
return new FileStreamResult(stream, contentType);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { message = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除资源包
|
||
/// </summary>
|
||
[HttpPost("packages/{id:long}/delete")]
|
||
[Authorize(Policy = "ProductManager")]
|
||
public async Task<ApiResponse> DeletePackage(long id)
|
||
{
|
||
return await _projectService.DeletePackageAsync(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载资源包(需要登录)
|
||
/// 通过后端代理流式返回文件,使用原始文件名设置 Content-Disposition
|
||
/// 解决:1.安卓端直接打开 COS 签名URL下载失败 2.下载文件名使用原始文件名而非 COS 对象键
|
||
/// </summary>
|
||
[HttpGet("packages/{id:long}/download")]
|
||
[Authorize]
|
||
public async Task<IActionResult> DownloadPackage(long id)
|
||
{
|
||
try
|
||
{
|
||
var (stream, contentType, fileName) = await _projectService.DownloadPackageAsync(id);
|
||
// attachment 触发浏览器下载,filename 使用原始文件名
|
||
return File(stream, contentType, fileName);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { message = ex.Message });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return StatusCode(500, new { message = $"下载失败: {ex.Message}" });
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 记录体验请求
|
||
/// </summary>
|
||
public record RecordExperienceRequest(int DurationSeconds);
|