using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Http; using MilliSim.Common.Dtos; using MilliSim.Common.Entities; using MilliSim.Common.Models; using MilliSim.Data; using MilliSim.Services.Infrastructure; using System.Text.RegularExpressions; using System.Net.Http.Headers; namespace MilliSim.Services; public class ProjectService : IProjectService { private readonly MilliSimDbContext _db; private readonly ICurrentUserService _currentUser; private readonly IFileStorageService _fileStorage; private readonly IOperationLogService _operationLog; private readonly INotificationService _notification; private readonly IUploadTrackingService _uploadTracking; // 静态 HttpClient 用于视频流式代理(避免每次请求创建新实例) private static readonly HttpClient _httpClient = new HttpClient(); public ProjectService( MilliSimDbContext db, ICurrentUserService currentUser, IFileStorageService fileStorage, IOperationLogService operationLog, INotificationService notification, IUploadTrackingService uploadTracking) { _db = db; _currentUser = currentUser; _fileStorage = fileStorage; _operationLog = operationLog; _notification = notification; _uploadTracking = uploadTracking; } // ===== 项目 ===== public Task>> GetProjectsAsync(ProjectQueryRequest request) => QueryProjectsAsync(request, publishedOnly: false); public Task>> GetPublishedProjectsAsync(ProjectQueryRequest request) => QueryProjectsAsync(request, publishedOnly: true); private async Task>> QueryProjectsAsync(ProjectQueryRequest request, bool publishedOnly) { var query = _db.Projects.AsQueryable(); if (publishedOnly) { query = query.Where(p => p.Status == ProjectStatus.Published); } if (request.CategoryId.HasValue) { query = query.Where(p => p.CategoryId == request.CategoryId.Value); } if (!string.IsNullOrEmpty(request.Platforms)) { query = query.Where(p => p.Platforms.Contains(request.Platforms)); } if (request.Status.HasValue && !publishedOnly) { query = query.Where(p => p.Status == request.Status.Value); } if (request.IsFeatured.HasValue) { query = query.Where(p => p.IsFeatured == request.IsFeatured.Value); } if (!string.IsNullOrEmpty(request.Keyword)) { var keyword = request.Keyword; query = query.Where(p => p.Name.Contains(keyword) || p.Description.Contains(keyword)); } query = request.SortBy switch { "popular" => query.OrderByDescending(p => p.ViewCount), "experience" => query.OrderByDescending(p => p.ExperienceCount), "favorite" => query.OrderByDescending(p => p.FavoriteCount), _ => query.OrderByDescending(p => p.CreatedAt) }; var total = await query.LongCountAsync(); var projects = await query .Skip((request.PageIndex - 1) * request.PageSize) .Take(request.PageSize) .ToListAsync(); var currentUserId = _currentUser.IsAuthenticated ? _currentUser.UserId : (long?)null; var dtos = await MapToDtoListAsync(projects, currentUserId); return ApiResponse>.Success( PagedResult.Create(dtos, total, request.PageIndex, request.PageSize)); } public async Task> GetProjectByIdAsync(long id, bool incrementView = false) { var project = await _db.Projects.FindAsync(id); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } // 前台访问只允许已发布项目 if (incrementView && project.Status != ProjectStatus.Published) { return ApiResponse.Fail("项目不存在", 404); } if (incrementView) { project.ViewCount += 1; await _db.SaveChangesAsync(); } var currentUserId = _currentUser.IsAuthenticated ? _currentUser.UserId : (long?)null; var dto = await MapToDtoAsync(project, currentUserId); return ApiResponse.Success(dto); } public async Task> CreateProjectAsync(CreateProjectRequest request) { // 创建项目:不再设置 VideoCover、IsFeatured、SortOrder(保留字段使用默认值),不再处理截图 // 保存时将签名URL转换为对象键存储,避免签名URL过期导致数据丢失 var project = new Project { Name = request.Name, CategoryId = request.CategoryId, CoverImage = _fileStorage.ExtractKey(request.CoverImage), VideoUrl = _fileStorage.ExtractKey(request.VideoUrl), Description = request.Description, Content = request.Content, Platforms = request.Platforms, Status = request.Status, PublishedAt = request.Status == ProjectStatus.Published ? DateTime.Now : null }; _db.Projects.Add(project); await _db.SaveChangesAsync(); await SyncProjectTagsAsync(project.Id, request.TagIds); await _db.SaveChangesAsync(); // 消费上传跟踪:标记本次上传的文件已保存到实体 await _uploadTracking.ConsumeAsync(_fileStorage.ExtractKey(project.CoverImage)); await _uploadTracking.ConsumeAsync(_fileStorage.ExtractKey(project.VideoUrl)); await _operationLog.LogAsync("项目管理", "创建项目", $"创建项目:{project.Name}"); var dto = await MapToDtoAsync(project); return ApiResponse.Success(dto); } public async Task> UpdateProjectAsync(long id, UpdateProjectRequest request) { var project = await _db.Projects.FindAsync(id); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } // 记录旧文件 key(用于对比变更后删除旧文件) var oldCoverKey = project.CoverImage; var oldVideoKey = project.VideoUrl; // 更新项目:不再设置 VideoCover、IsFeatured、SortOrder(保留字段使用默认值),不再处理截图 // 保存时将签名URL转换为对象键存储,避免签名URL过期导致数据丢失 project.Name = request.Name; project.CategoryId = request.CategoryId; var newCoverKey = _fileStorage.ExtractKey(request.CoverImage); project.CoverImage = newCoverKey; // VideoUrl:详情接口返回代理URL(/api/projects/{id}/video),编辑页回显该代理URL, // 未更换视频时回传的仍是代理URL。此时跳过更新,避免 ExtractKey 把代理URL变成脏 key 覆盖原 COS key。 var newVideoKey = oldVideoKey; if (!string.IsNullOrEmpty(request.VideoUrl) && !request.VideoUrl.StartsWith("/api/projects/", StringComparison.OrdinalIgnoreCase)) { newVideoKey = _fileStorage.ExtractKey(request.VideoUrl); project.VideoUrl = newVideoKey; } project.Description = request.Description; project.Content = request.Content; project.Platforms = request.Platforms; project.UpdatedAt = DateTime.Now; if (project.Status != request.Status) { project.Status = request.Status; if (request.Status == ProjectStatus.Published && project.PublishedAt == null) { project.PublishedAt = DateTime.Now; } } await SyncProjectTagsAsync(id, request.TagIds, clearExisting: true); // 同步更新已上传资源包的唤醒协议参数(仅更新 Id>0 的已有资源包,不涉及文件上传) if (request.Packages != null && request.Packages.Any()) { var packageIds = request.Packages.Where(p => p.Id > 0).Select(p => p.Id).ToList(); if (packageIds.Any()) { var existingPackages = await _db.ProjectPackages .Where(p => packageIds.Contains(p.Id) && p.ProjectId == id) .ToListAsync(); foreach (var pkg in existingPackages) { var updatePkg = request.Packages.First(p => p.Id == pkg.Id); pkg.WakeProtocolPrefix = updatePkg.WakeProtocolPrefix ?? string.Empty; pkg.WakeProtocolParam = updatePkg.WakeProtocolParam ?? string.Empty; } } } await _db.SaveChangesAsync(); // 封面图替换:删除旧文件 if (!string.IsNullOrEmpty(oldCoverKey) && oldCoverKey != newCoverKey) { await _fileStorage.DeleteAsync(oldCoverKey); } // 视频替换:删除旧文件 if (!string.IsNullOrEmpty(oldVideoKey) && oldVideoKey != newVideoKey) { await _fileStorage.DeleteAsync(oldVideoKey); } // 消费新上传的文件 await _uploadTracking.ConsumeAsync(newCoverKey); await _uploadTracking.ConsumeAsync(newVideoKey); await _operationLog.LogAsync("项目管理", "更新项目", $"更新项目:{project.Name}"); var dto = await MapToDtoAsync(project); return ApiResponse.Success(dto); } public async Task DeleteProjectAsync(long id) { var project = await _db.Projects.FindAsync(id); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } project.IsDeleted = true; project.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("项目管理", "删除项目", $"删除项目:{project.Name}"); return ApiResponse.Success("删除成功"); } public async Task TogglePublishAsync(long id) { var project = await _db.Projects.FindAsync(id); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } if (project.Status == ProjectStatus.Published) { project.Status = ProjectStatus.Offline; await _operationLog.LogAsync("项目管理", "下架项目", $"下架项目:{project.Name}"); } else { project.Status = ProjectStatus.Published; if (project.PublishedAt == null) { project.PublishedAt = DateTime.Now; } await _operationLog.LogAsync("项目管理", "发布项目", $"发布项目:{project.Name}"); // 发布后广播通知所有用户(UserId=null 表示全员) await _notification.CreateNotificationAsync( NotificationType.Project, "新项目发布", $"《{project.Name}》已上线,快来体验吧!", userId: null, eventId: $"project-published-{project.Id}"); } project.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); return ApiResponse.Success(project.Status == ProjectStatus.Published ? "已发布" : "已下架"); } public async Task ToggleFeaturedAsync(long id) { var project = await _db.Projects.FindAsync(id); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } project.IsFeatured = !project.IsFeatured; project.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("项目管理", "切换推荐", $"项目 {project.Name} 推荐状态:{project.IsFeatured}"); return ApiResponse.Success(project.IsFeatured ? "已推荐" : "已取消推荐"); } public async Task ToggleFavoriteAsync(long projectId) { var userId = _currentUser.UserId; if (userId == 0) { return ApiResponse.Fail("用户未登录", 401); } var project = await _db.Projects.FindAsync(projectId); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } var existing = await _db.Favorites .FirstOrDefaultAsync(f => f.UserId == userId && f.ProjectId == projectId); if (existing != null) { _db.Favorites.Remove(existing); project.FavoriteCount = Math.Max(0, project.FavoriteCount - 1); await _db.SaveChangesAsync(); return ApiResponse.Success("已取消收藏"); } _db.Favorites.Add(new Favorite { UserId = userId, ProjectId = projectId }); project.FavoriteCount += 1; await _db.SaveChangesAsync(); return ApiResponse.Success("已收藏"); } public async Task>> GetFavoritesAsync(PageRequest request) { var userId = _currentUser.UserId; if (userId == 0) { return ApiResponse>.Fail("用户未登录", 401); } var query = _db.Favorites .Where(f => f.UserId == userId) .OrderByDescending(f => f.CreatedAt); var total = await query.LongCountAsync(); var favorites = await query .Skip((request.PageIndex - 1) * request.PageSize) .Take(request.PageSize) .ToListAsync(); var projectIds = favorites.Select(f => f.ProjectId).ToList(); var projects = await _db.Projects .Where(p => projectIds.Contains(p.Id)) .ToListAsync(); // 保持收藏顺序 var orderedProjects = projectIds .Select(pid => projects.FirstOrDefault(p => p.Id == pid)) .Where(p => p != null) .Cast() .ToList(); var dtos = await MapToDtoListAsync(orderedProjects, userId); return ApiResponse>.Success( PagedResult.Create(dtos, total, request.PageIndex, request.PageSize)); } public async Task RecordExperienceAsync(long projectId, int durationSeconds) { var userId = _currentUser.UserId; if (userId == 0) { return ApiResponse.Fail("用户未登录", 401); } var project = await _db.Projects.FindAsync(projectId); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } _db.ExperienceLogs.Add(new ExperienceLog { UserId = userId, ProjectId = projectId, DurationSeconds = durationSeconds }); project.ExperienceCount += 1; await _db.SaveChangesAsync(); return ApiResponse.Success("体验记录已保存"); } public async Task>> GetExperienceLogsAsync(PageRequest request) { var userId = _currentUser.UserId; if (userId == 0) { return ApiResponse>.Fail("用户未登录", 401); } var query = _db.ExperienceLogs .Where(e => e.UserId == userId) .OrderByDescending(e => e.CreatedAt); var total = await query.LongCountAsync(); var logs = await query .Skip((request.PageIndex - 1) * request.PageSize) .Take(request.PageSize) .ToListAsync(); // 一次性查出关联项目(含已删除项目的兜底:缺失则返回空字段) var projectIds = logs.Select(l => l.ProjectId).Distinct().ToList(); var projects = await _db.Projects .Where(p => projectIds.Contains(p.Id)) .ToListAsync(); var dtos = logs.Select(l => { var project = projects.FirstOrDefault(p => p.Id == l.ProjectId); return new ExperienceLogDto { Id = l.Id, ProjectId = l.ProjectId, ProjectName = project?.Name ?? string.Empty, // 返回签名URL,确保前端能访问COS私有文件 ProjectCover = _fileStorage.GetSignedUrl(project?.CoverImage ?? string.Empty), DurationSeconds = l.DurationSeconds, CreatedAt = l.CreatedAt }; }).ToList(); return ApiResponse>.Success( PagedResult.Create(dtos, total, request.PageIndex, request.PageSize)); } public async Task>> GetRelatedProjectsAsync(long projectId, int count = 4) { var project = await _db.Projects.FindAsync(projectId); if (project == null) { return ApiResponse>.Fail("项目不存在", 404); } var related = await _db.Projects .Where(p => p.CategoryId == project.CategoryId && p.Id != projectId && p.Status == ProjectStatus.Published) .OrderByDescending(p => p.IsFeatured) .ThenByDescending(p => p.ViewCount) .Take(count) .ToListAsync(); var dtos = await MapToDtoListAsync(related); return ApiResponse>.Success(dtos); } // ===== 项目分类 ===== public async Task>> GetCategoriesAsync() { var categories = await _db.ProjectCategories .OrderBy(c => c.SortOrder) .Select(c => new { c.Id, c.Name, c.Icon, c.SortOrder, c.Status }) .ToListAsync(); var dtos = categories.Select(c => new CategoryDto { Id = c.Id, Name = c.Name, Icon = string.IsNullOrEmpty(c.Icon) ? string.Empty : _fileStorage.GetSignedUrl(c.Icon), SortOrder = c.SortOrder, Status = c.Status }).ToList(); return ApiResponse>.Success(dtos); } public async Task> CreateCategoryAsync(CreateCategoryRequest request) { var category = new ProjectCategory { Name = request.Name, Icon = _fileStorage.ExtractKey(request.Icon), SortOrder = request.SortOrder, Status = UserStatus.Active }; _db.ProjectCategories.Add(category); await _db.SaveChangesAsync(); // 消费上传跟踪 await _uploadTracking.ConsumeAsync(_fileStorage.ExtractKey(request.Icon)); await _operationLog.LogAsync("分类管理", "创建分类", $"创建分类:{category.Name}"); return ApiResponse.Success(ToDto(category)); } public async Task> UpdateCategoryAsync(long id, CreateCategoryRequest request) { var category = await _db.ProjectCategories.FindAsync(id); if (category == null) { return ApiResponse.Fail("分类不存在", 404); } var oldIconKey = _fileStorage.ExtractKey(category.Icon); var newIconKey = _fileStorage.ExtractKey(request.Icon); category.Name = request.Name; category.Icon = _fileStorage.ExtractKey(request.Icon); category.SortOrder = request.SortOrder; category.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); // 图标替换:删除旧文件 if (!string.IsNullOrEmpty(oldIconKey) && oldIconKey != newIconKey) { await _fileStorage.DeleteAsync(oldIconKey); } // 消费新上传的文件 await _uploadTracking.ConsumeAsync(newIconKey); await _operationLog.LogAsync("分类管理", "更新分类", $"更新分类:{category.Name}"); return ApiResponse.Success(ToDto(category)); } public async Task DeleteCategoryAsync(long id) { var category = await _db.ProjectCategories.FindAsync(id); if (category == null) { return ApiResponse.Fail("分类不存在", 404); } var hasProjects = await _db.Projects.AnyAsync(p => p.CategoryId == id); if (hasProjects) { return ApiResponse.Fail("该分类下存在项目,无法删除"); } category.IsDeleted = true; category.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("分类管理", "删除分类", $"删除分类:{category.Name}"); return ApiResponse.Success("删除成功"); } // ===== 平台类型 ===== public async Task>> GetPlatformsAsync() { var platforms = await _db.PlatformTypes .OrderBy(p => p.SortOrder) .Select(p => new { p.Id, p.Name, p.Icon, p.SortOrder, p.Status }) .ToListAsync(); var dtos = platforms.Select(p => new PlatformDto { Id = p.Id, Name = p.Name, Icon = string.IsNullOrEmpty(p.Icon) ? string.Empty : _fileStorage.GetSignedUrl(p.Icon), SortOrder = p.SortOrder, Status = p.Status }).ToList(); return ApiResponse>.Success(dtos); } public async Task> CreatePlatformAsync(CreatePlatformRequest request) { var platform = new PlatformType { Name = request.Name, Icon = _fileStorage.ExtractKey(request.Icon), SortOrder = request.SortOrder, Status = UserStatus.Active }; _db.PlatformTypes.Add(platform); await _db.SaveChangesAsync(); // 消费上传跟踪 await _uploadTracking.ConsumeAsync(_fileStorage.ExtractKey(request.Icon)); await _operationLog.LogAsync("平台管理", "创建平台", $"创建平台:{platform.Name}"); return ApiResponse.Success(ToDto(platform)); } public async Task> UpdatePlatformAsync(long id, CreatePlatformRequest request) { var platform = await _db.PlatformTypes.FindAsync(id); if (platform == null) { return ApiResponse.Fail("平台不存在", 404); } var oldIconKey = _fileStorage.ExtractKey(platform.Icon); var newIconKey = _fileStorage.ExtractKey(request.Icon); platform.Name = request.Name; platform.Icon = _fileStorage.ExtractKey(request.Icon); platform.SortOrder = request.SortOrder; platform.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); // 图标替换:删除旧文件 if (!string.IsNullOrEmpty(oldIconKey) && oldIconKey != newIconKey) { await _fileStorage.DeleteAsync(oldIconKey); } // 消费新上传的文件 await _uploadTracking.ConsumeAsync(newIconKey); await _operationLog.LogAsync("平台管理", "更新平台", $"更新平台:{platform.Name}"); return ApiResponse.Success(ToDto(platform)); } public async Task DeletePlatformAsync(long id) { var platform = await _db.PlatformTypes.FindAsync(id); if (platform == null) { return ApiResponse.Fail("平台不存在", 404); } platform.IsDeleted = true; platform.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("平台管理", "删除平台", $"删除平台:{platform.Name}"); return ApiResponse.Success("删除成功"); } // ===== 特性标签 ===== public async Task>> GetTagsAsync() { var tags = await _db.FeatureTags .OrderBy(t => t.SortOrder) .Select(t => new TagDto { Id = t.Id, Name = t.Name, Icon = t.Icon, SortOrder = t.SortOrder, Status = t.Status }) .ToListAsync(); return ApiResponse>.Success(tags); } public async Task> CreateTagAsync(CreateTagRequest request) { var tag = new FeatureTag { Name = request.Name, Icon = request.Icon, SortOrder = request.SortOrder, Status = UserStatus.Active }; _db.FeatureTags.Add(tag); await _db.SaveChangesAsync(); // 消费上传跟踪 await _uploadTracking.ConsumeAsync(_fileStorage.ExtractKey(request.Icon)); await _operationLog.LogAsync("标签管理", "创建标签", $"创建标签:{tag.Name}"); return ApiResponse.Success(ToDto(tag)); } public async Task> UpdateTagAsync(long id, CreateTagRequest request) { var tag = await _db.FeatureTags.FindAsync(id); if (tag == null) { return ApiResponse.Fail("标签不存在", 404); } var oldIconKey = _fileStorage.ExtractKey(tag.Icon); var newIconKey = _fileStorage.ExtractKey(request.Icon); tag.Name = request.Name; tag.Icon = request.Icon; tag.SortOrder = request.SortOrder; tag.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); // 图标替换:删除旧文件 if (!string.IsNullOrEmpty(oldIconKey) && oldIconKey != newIconKey) { await _fileStorage.DeleteAsync(oldIconKey); } // 消费新上传的文件 await _uploadTracking.ConsumeAsync(newIconKey); await _operationLog.LogAsync("标签管理", "更新标签", $"更新标签:{tag.Name}"); return ApiResponse.Success(ToDto(tag)); } public async Task DeleteTagAsync(long id) { var tag = await _db.FeatureTags.FindAsync(id); if (tag == null) { return ApiResponse.Fail("标签不存在", 404); } var hasRelations = await _db.ProjectTagRelations.AnyAsync(r => r.TagId == id); if (hasRelations) { return ApiResponse.Fail("该标签已被项目使用,无法删除"); } tag.IsDeleted = true; tag.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("标签管理", "删除标签", $"删除标签:{tag.Name}"); return ApiResponse.Success("删除成功"); } // ===== 资源包 ===== public async Task>> GetPackagesAsync(long projectId) { // 先查询实体,再在内存中转换为DTO(因为需要调用GetSignedUrl生成签名URL) var entities = await _db.ProjectPackages .Where(p => p.ProjectId == projectId) .OrderByDescending(p => p.CreatedAt) .ToListAsync(); // 返回时将对象键转换为签名URL var packages = entities.Select(p => new PackageDto { Id = p.Id, ProjectId = p.ProjectId, Type = p.Type, FileName = p.FileName, Url = _fileStorage.GetSignedUrl(p.Url), FileSize = p.FileSize, CreatedAt = p.CreatedAt, WakeProtocolPrefix = p.WakeProtocolPrefix, WakeProtocolParam = p.WakeProtocolParam, ExtractedPath = p.ExtractedPath }).ToList(); return ApiResponse>.Success(packages); } public async Task> UploadPackageAsync(long projectId, PackageType type, IFormFile file, string wakeProtocolPrefix, string wakeProtocolParam) { var project = await _db.Projects.FindAsync(projectId); if (project == null) { return ApiResponse.Fail("项目不存在", 404); } if (file == null || file.Length == 0) { return ApiResponse.Fail("文件不能为空"); } // 上传资源包文件(保存原始压缩包,用于下载) var url = await _fileStorage.UploadAsync(file, "packages"); // WebGL 类型:解压 ZIP 到 COS,用于在线运行 string extractedPath = string.Empty; if (type == PackageType.WebGL) { try { extractedPath = await _fileStorage.ExtractZipAsync(file, "webgl"); } catch (Exception ex) { // 解压失败不阻断上传,但记录日志 await _operationLog.LogAsync("资源包管理", "WebGL解压失败", $"项目:{project.Name},错误:{ex.Message}"); } } var package = new ProjectPackage { ProjectId = projectId, Type = type, FileName = file.FileName, Url = url, FileSize = file.Length, WakeProtocolPrefix = wakeProtocolPrefix ?? string.Empty, WakeProtocolParam = wakeProtocolParam ?? string.Empty, ExtractedPath = extractedPath }; _db.ProjectPackages.Add(package); await _db.SaveChangesAsync(); await _operationLog.LogAsync("资源包管理", "上传资源包", $"上传资源包:{file.FileName} 到项目:{project.Name}"); return ApiResponse.Success(ToDto(package)); } /// /// 获取 WebGL 资源包的在线运行入口 URL /// 查找项目下 WebGL 类型的资源包,返回解压后 index.html 的签名 URL /// public async Task> GetWebGLRunUrlAsync(long projectId) { var pkg = await _db.ProjectPackages .Where(p => p.ProjectId == projectId && p.Type == PackageType.WebGL) .OrderByDescending(p => p.CreatedAt) .FirstOrDefaultAsync(); if (pkg == null) { return ApiResponse.Fail("该项目暂无 WebGL 资源包", 404); } if (string.IsNullOrEmpty(pkg.ExtractedPath)) { return ApiResponse.Fail("WebGL 资源包尚未解压,请重新上传", 400); } // 拼接 index.html 的对象键并生成签名 URL var indexKey = pkg.ExtractedPath.TrimEnd('/') + "/index.html"; var signedUrl = _fileStorage.GetSignedUrl(indexKey); return ApiResponse.Success(signedUrl); } /// /// 获取 WebGL 代理后的 index.html 内容 /// 浏览器无法直接渲染 COS 私有桶的 index.html(相对路径资源无签名参数会 403) /// 方案:后端代理读取 index.html,注入 <base> 标签指向资源代理端点 /// 所有相对路径资源自动解析到 /api/projects/{id}/webgl-resource/{path} /// 资源代理端点返回 302 重定向到签名 URL,浏览器直接从 COS 加载 /// public async Task GetWebGLIndexHtmlAsync(long projectId) { var pkg = await _db.ProjectPackages .Where(p => p.ProjectId == projectId && p.Type == PackageType.WebGL) .OrderByDescending(p => p.CreatedAt) .FirstOrDefaultAsync(); if (pkg == null) { throw new InvalidOperationException("该项目暂无 WebGL 资源包"); } if (string.IsNullOrEmpty(pkg.ExtractedPath)) { throw new InvalidOperationException("WebGL 资源包尚未解压,请重新上传"); } // 读取 index.html 内容 var indexKey = pkg.ExtractedPath.TrimEnd('/') + "/index.html"; var html = await _fileStorage.ReadFileTextAsync(indexKey); // 注入 标签:所有相对路径解析到资源代理端点 // 浏览器加载 index.html 后,相对路径如 Build/xxx.js 会解析为 // /api/projects/{id}/webgl-resource/Build/xxx.js // 该端点返回 302 重定向到 COS 签名 URL,浏览器直接从 COS 加载资源 var baseTag = $""; // 将 标签插入到 之后;若无 则插入到 之后;都没有则前置 if (Regex.IsMatch(html, @"]*>", RegexOptions.IgnoreCase)) { html = Regex.Replace(html, @"(]*>)", $"$1{baseTag}", RegexOptions.IgnoreCase); } else if (Regex.IsMatch(html, @"]*>", RegexOptions.IgnoreCase)) { html = Regex.Replace(html, @"(]*>)", $"$1{baseTag}", RegexOptions.IgnoreCase); } else { html = baseTag + html; } return html; } /// /// 获取 WebGL 资源文件的签名 URL(用于资源代理重定向) /// 前端通过 /api/projects/{id}/webgl-resource/{path} 访问资源 /// 后端生成签名 URL 后返回 302 重定向,浏览器直接从 COS 加载 /// public async Task GetWebGLResourceUrlAsync(long projectId, string resourcePath) { var pkg = await _db.ProjectPackages .Where(p => p.ProjectId == projectId && p.Type == PackageType.WebGL) .OrderByDescending(p => p.CreatedAt) .FirstOrDefaultAsync(); if (pkg == null || string.IsNullOrEmpty(pkg.ExtractedPath)) { throw new InvalidOperationException("WebGL 资源不存在"); } // 拼接资源对象键:解压根路径 + 资源相对路径 var basePath = pkg.ExtractedPath.TrimEnd('/') + "/"; var relativePath = resourcePath.TrimStart('/'); var key = basePath + relativePath; return _fileStorage.GetSignedUrl(key); } /// /// 获取视频流(防盗链代理,不暴露存储直链) /// COS模式通过 HttpClient 流式转发,Local模式直接读取本地文件,均支持 Range 请求(视频拖动) /// public async Task<(Stream Stream, string ContentType, long? FileSize, bool IsPartial, string? ContentRange)> GetVideoStreamAsync(long projectId, string? rangeHeader) { // 查询项目的视频对象键 var videoKey = await _db.Projects .Where(p => p.Id == projectId && !p.IsDeleted) .Select(p => p.VideoUrl) .FirstOrDefaultAsync(); if (string.IsNullOrEmpty(videoKey)) { throw new InvalidOperationException("视频不存在"); } // 生成签名 URL(Local 模式返回相对路径如 /uploads/videos/xxx.mp4) var signedUrl = _fileStorage.GetSignedUrl(videoKey); if (string.IsNullOrEmpty(signedUrl)) { throw new InvalidOperationException("视频不存在"); } // Local 模式:签名 URL 是相对路径,无法用 HttpClient 请求,改为直接读取本地文件 if (!signedUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { var (stream, contentType, _) = await _fileStorage.DownloadAsync(videoKey); // DownloadAsync 返回 FileStream,可 Seek,支持 Range if (stream.CanSeek) { var fileSize = stream.Length; long start = 0, end = fileSize - 1; var isPartial = false; if (!string.IsNullOrEmpty(rangeHeader)) { try { var range = RangeHeaderValue.Parse(rangeHeader); if (range.Ranges.Count > 0) { var r = range.Ranges.First(); start = r.From ?? 0; end = r.To ?? fileSize - 1; isPartial = true; } } catch { /* 无效 Range 头,返回完整内容 */ } } var length = end - start + 1; var contentRange = isPartial ? $"bytes {start}-{end}/{fileSize}" : null; // 部分请求时用 PartialReadStream 包装,使 FileStreamResult 正确计算 Content-Length // 否则 FileStreamResult 会用 stream.Length(完整文件大小)覆盖 Content-Length,导致与 Content-Range 不匹配 Stream resultStream = isPartial ? new PartialReadStream(stream, start, end) : stream; return (resultStream, contentType ?? "video/mp4", length, isPartial, contentRange); } return (stream, contentType ?? "video/mp4", null, false, null); } // COS 模式:通过 HttpClient 流式转发,支持 Range 请求 var request = new HttpRequestMessage(HttpMethod.Get, signedUrl); if (!string.IsNullOrEmpty(rangeHeader)) { request.Headers.Range = RangeHeaderValue.Parse(rangeHeader); } // 流式获取响应(只读取响应头,内容流式读取) var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); var isPartialResp = response.StatusCode == System.Net.HttpStatusCode.PartialContent; if (!response.IsSuccessStatusCode && !isPartialResp) { response.Dispose(); throw new InvalidOperationException("视频加载失败"); } var cosStream = await response.Content.ReadAsStreamAsync(); var cosContentType = response.Content.Headers.ContentType?.MediaType ?? "video/mp4"; var cosFileSize = response.Content.Headers.ContentLength; var cosContentRange = response.Content.Headers.ContentRange?.ToString(); return (cosStream, cosContentType, cosFileSize, isPartialResp, cosContentRange); } public async Task DeletePackageAsync(long id) { var package = await _db.ProjectPackages.FindAsync(id); if (package == null) { return ApiResponse.Fail("资源包不存在", 404); } package.IsDeleted = true; package.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("资源包管理", "删除资源包", $"删除资源包 ID:{id}"); return ApiResponse.Success("删除成功"); } /// /// 下载资源包(通过后端代理流式返回,使用原始文件名设置 Content-Disposition) /// 解决两个问题: /// 1. 安卓端 window.open(COS签名URL) 下载失败(URL 过长/弹窗被拦截) /// 2. 下载文件名需使用原始文件名而非 COS 对象键(时间戳_GUID.扩展名) /// public async Task<(Stream Stream, string ContentType, string FileName)> DownloadPackageAsync(long packageId) { var package = await _db.ProjectPackages.FindAsync(packageId); if (package == null || package.IsDeleted) { throw new InvalidOperationException("资源包不存在"); } // 通过 FileStorageService 下载文件流(当前模式失败时自动回退到另一存储模式) Stream stream; string contentType; try { (stream, contentType, _) = await _fileStorage.DownloadAsync(package.Url); } catch (Exception ex) { // 两种存储模式都失败,提供包含对象键的详细错误信息 throw new InvalidOperationException($"资源包文件无法访问(对象键:{package.Url}),请尝试重新上传。错误:{ex.Message}"); } // 使用原始文件名(上传时的文件名),而非存储中的对象键 var fileName = string.IsNullOrEmpty(package.FileName) ? Path.GetFileName(package.Url) : package.FileName; await _operationLog.LogAsync("资源包管理", "下载资源包", $"下载资源包:{fileName}"); return (stream, contentType, fileName); } // ===== 私有辅助方法 ===== private async Task SyncProjectTagsAsync(long projectId, List tagIds, bool clearExisting = false) { if (clearExisting) { var existing = _db.ProjectTagRelations.Where(r => r.ProjectId == projectId); _db.ProjectTagRelations.RemoveRange(existing); } if (tagIds != null && tagIds.Any()) { foreach (var tagId in tagIds.Distinct()) { _db.ProjectTagRelations.Add(new ProjectTagRelation { ProjectId = projectId, TagId = tagId }); } } await Task.CompletedTask; } // SyncProjectScreenshotsAsync 已移除(截图功能已废弃,不再处理截图) private async Task> MapToDtoListAsync(List projects, long? currentUserId = null) { if (!projects.Any()) { return new List(); } var projectIds = projects.Select(p => p.Id).ToList(); var categoryIds = projects.Select(p => p.CategoryId).Distinct().ToList(); // 查询分类(含图标) var categoryData = await _db.ProjectCategories .Where(c => categoryIds.Contains(c.Id)) .Select(c => new { c.Id, c.Name, c.Icon }) .ToListAsync(); var categoryDict = categoryData.ToDictionary(c => c.Id); // 查询标签关系(含标签颜色,Tag.Icon 存储颜色 hex 值) var tagRelations = await _db.ProjectTagRelations .Where(r => projectIds.Contains(r.ProjectId)) .Select(r => new { r.ProjectId, r.Tag.Name, r.Tag.Icon }) .ToListAsync(); var tagsByProject = tagRelations .GroupBy(t => t.ProjectId) .ToDictionary(g => g.Key, g => g.Select(t => new { t.Name, Color = t.Icon }).ToList()); // 查询平台类型(含图标),按名称匹配 var allPlatformNames = projects .Where(p => !string.IsNullOrEmpty(p.Platforms)) .SelectMany(p => p.Platforms.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim())) .Where(s => !string.IsNullOrWhiteSpace(s)) .Distinct() .ToList(); var platformData = allPlatformNames.Any() ? await _db.PlatformTypes .Where(pt => allPlatformNames.Contains(pt.Name)) .Select(pt => new { pt.Name, pt.Icon }) .ToListAsync() : new(); var platformDict = platformData.ToDictionary(pt => pt.Name); // 不再查询截图(截图功能已废弃) var favoritedIds = new HashSet(); if (currentUserId.HasValue && currentUserId.Value > 0) { var favorited = await _db.Favorites .Where(f => f.UserId == currentUserId.Value && projectIds.Contains(f.ProjectId)) .Select(f => f.ProjectId) .ToListAsync(); favoritedIds = favorited.ToHashSet(); } return projects.Select(p => { categoryDict.TryGetValue(p.CategoryId, out var cat); var categoryName = cat?.Name ?? string.Empty; var categoryIconRaw = cat?.Icon ?? string.Empty; var platformNames = string.IsNullOrEmpty(p.Platforms) ? new List() : p.Platforms.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()).ToList(); var tagList = tagsByProject.GetValueOrDefault(p.Id) ?? new(); return new ProjectDto { Id = p.Id, Name = p.Name, CategoryId = p.CategoryId, CategoryName = categoryName, CategoryIcon = string.IsNullOrEmpty(categoryIconRaw) ? string.Empty : _fileStorage.GetSignedUrl(categoryIconRaw), // 返回时将对象键转换为签名URL,确保前端能访问COS私有文件 CoverImage = _fileStorage.GetSignedUrl(p.CoverImage), // 视频走代理端点,不暴露 COS 签名直链(防盗链) VideoUrl = string.IsNullOrEmpty(p.VideoUrl) ? string.Empty : $"/api/projects/{p.Id}/video", VideoCover = p.VideoCover, Description = p.Description, Content = p.Content, Platforms = p.Platforms, PlatformList = platformNames, PlatformItems = platformNames.Select(name => new PlatformItemDto { Name = name, Icon = platformDict.TryGetValue(name, out var pt) && !string.IsNullOrEmpty(pt.Icon) ? _fileStorage.GetSignedUrl(pt.Icon) : string.Empty }).ToList(), Status = p.Status, IsFeatured = p.IsFeatured, SortOrder = p.SortOrder, ViewCount = p.ViewCount, ExperienceCount = p.ExperienceCount, FavoriteCount = p.FavoriteCount, PublishedAt = p.PublishedAt, CreatedAt = p.CreatedAt, Tags = tagList.Select(t => t.Name).ToList(), TagItems = tagList.Select(t => new TagItemDto { Name = t.Name, Color = t.Color ?? string.Empty }).ToList(), // 截图功能已废弃,返回空列表 Screenshots = new List(), IsFavorited = favoritedIds.Contains(p.Id) }; }).ToList(); } private async Task MapToDtoAsync(Project project, long? currentUserId = null) { var list = await MapToDtoListAsync(new List { project }, currentUserId); return list.First(); } private CategoryDto ToDto(ProjectCategory c) => new() { Id = c.Id, Name = c.Name, Icon = string.IsNullOrEmpty(c.Icon) ? string.Empty : _fileStorage.GetSignedUrl(c.Icon), SortOrder = c.SortOrder, Status = c.Status }; private PlatformDto ToDto(PlatformType p) => new() { Id = p.Id, Name = p.Name, Icon = string.IsNullOrEmpty(p.Icon) ? string.Empty : _fileStorage.GetSignedUrl(p.Icon), SortOrder = p.SortOrder, Status = p.Status }; private static TagDto ToDto(FeatureTag t) => new() { Id = t.Id, Name = t.Name, Icon = t.Icon, SortOrder = t.SortOrder, Status = t.Status }; // 改为非静态方法,以便调用 _fileStorage.GetSignedUrl 生成签名URL private PackageDto ToDto(ProjectPackage p) => new() { Id = p.Id, ProjectId = p.ProjectId, Type = p.Type, FileName = p.FileName, // 返回时将对象键转换为签名URL Url = _fileStorage.GetSignedUrl(p.Url), FileSize = p.FileSize, CreatedAt = p.CreatedAt, WakeProtocolPrefix = p.WakeProtocolPrefix, WakeProtocolParam = p.WakeProtocolParam, ExtractedPath = p.ExtractedPath }; /// /// 限制读取范围的流包装器,使包装后的流仅暴露请求范围内的字节 /// 用于视频 Range 请求:FileStreamResult 会用 stream.Length 设置 Content-Length, /// 不包装的话会返回完整文件大小,与 Content-Range 不匹配导致浏览器播放失败 /// private sealed class PartialReadStream : Stream { private readonly Stream _baseStream; private readonly long _start; private readonly long _end; private long _position; public PartialReadStream(Stream baseStream, long start, long end) { _baseStream = baseStream; _start = start; _end = end; _position = 0; if (baseStream.CanSeek) { baseStream.Position = start; } } public override bool CanRead => _baseStream.CanRead; public override bool CanSeek => _baseStream.CanSeek; public override bool CanWrite => false; public override long Length => _end - _start + 1; public override long Position { get => _position; set { _position = value; if (_baseStream.CanSeek) { _baseStream.Position = _start + value; } } } public override int Read(byte[] buffer, int offset, int count) { var remaining = Length - _position; if (remaining <= 0) return 0; var toRead = (int)Math.Min(count, remaining); var read = _baseStream.Read(buffer, offset, toRead); _position += read; return read; } public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { var remaining = Length - _position; if (remaining <= 0) return 0; var toRead = (int)Math.Min(count, remaining); var read = await _baseStream.ReadAsync(buffer, offset, toRead, cancellationToken); _position += read; return read; } public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { var remaining = Length - _position; if (remaining <= 0) return ValueTask.FromResult(0); var toRead = (int)Math.Min(buffer.Length, remaining); return ReadAsyncCore(buffer.Slice(0, toRead), cancellationToken); } private async ValueTask ReadAsyncCore(Memory buffer, CancellationToken cancellationToken) { var read = await _baseStream.ReadAsync(buffer, cancellationToken); _position += read; return read; } public override long Seek(long offset, SeekOrigin origin) { var newPos = origin switch { SeekOrigin.Begin => offset, SeekOrigin.Current => _position + offset, SeekOrigin.End => Length + offset, _ => throw new ArgumentOutOfRangeException(nameof(origin)) }; _position = newPos; if (_baseStream.CanSeek) { _baseStream.Position = _start + newPos; } return newPos; } public override void Flush() => _baseStream.Flush(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { if (disposing) { _baseStream.Dispose(); } base.Dispose(disposing); } } }