MilliSim/backend/MilliSim.Api/Controllers/ProjectsController.cs

303 lines
9.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);