1374 lines
50 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.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<ApiResponse<PagedResult<ProjectDto>>> GetProjectsAsync(ProjectQueryRequest request)
=> QueryProjectsAsync(request, publishedOnly: false);
public Task<ApiResponse<PagedResult<ProjectDto>>> GetPublishedProjectsAsync(ProjectQueryRequest request)
=> QueryProjectsAsync(request, publishedOnly: true);
private async Task<ApiResponse<PagedResult<ProjectDto>>> 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<PagedResult<ProjectDto>>.Success(
PagedResult<ProjectDto>.Create(dtos, total, request.PageIndex, request.PageSize));
}
public async Task<ApiResponse<ProjectDto>> GetProjectByIdAsync(long id, bool incrementView = false)
{
var project = await _db.Projects.FindAsync(id);
if (project == null)
{
return ApiResponse<ProjectDto>.Fail("项目不存在", 404);
}
// 前台访问只允许已发布项目
if (incrementView && project.Status != ProjectStatus.Published)
{
return ApiResponse<ProjectDto>.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<ProjectDto>.Success(dto);
}
public async Task<ApiResponse<ProjectDto>> 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<ProjectDto>.Success(dto);
}
public async Task<ApiResponse<ProjectDto>> UpdateProjectAsync(long id, UpdateProjectRequest request)
{
var project = await _db.Projects.FindAsync(id);
if (project == null)
{
return ApiResponse<ProjectDto>.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<ProjectDto>.Success(dto);
}
public async Task<ApiResponse> 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<ApiResponse> 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<ApiResponse> 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<ApiResponse> 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<ApiResponse<PagedResult<ProjectDto>>> GetFavoritesAsync(PageRequest request)
{
var userId = _currentUser.UserId;
if (userId == 0)
{
return ApiResponse<PagedResult<ProjectDto>>.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<Project>()
.ToList();
var dtos = await MapToDtoListAsync(orderedProjects, userId);
return ApiResponse<PagedResult<ProjectDto>>.Success(
PagedResult<ProjectDto>.Create(dtos, total, request.PageIndex, request.PageSize));
}
public async Task<ApiResponse> 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<ApiResponse<PagedResult<ExperienceLogDto>>> GetExperienceLogsAsync(PageRequest request)
{
var userId = _currentUser.UserId;
if (userId == 0)
{
return ApiResponse<PagedResult<ExperienceLogDto>>.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<PagedResult<ExperienceLogDto>>.Success(
PagedResult<ExperienceLogDto>.Create(dtos, total, request.PageIndex, request.PageSize));
}
public async Task<ApiResponse<List<ProjectDto>>> GetRelatedProjectsAsync(long projectId, int count = 4)
{
var project = await _db.Projects.FindAsync(projectId);
if (project == null)
{
return ApiResponse<List<ProjectDto>>.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<List<ProjectDto>>.Success(dtos);
}
// ===== 项目分类 =====
public async Task<ApiResponse<List<CategoryDto>>> 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<List<CategoryDto>>.Success(dtos);
}
public async Task<ApiResponse<CategoryDto>> 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<CategoryDto>.Success(ToDto(category));
}
public async Task<ApiResponse<CategoryDto>> UpdateCategoryAsync(long id, CreateCategoryRequest request)
{
var category = await _db.ProjectCategories.FindAsync(id);
if (category == null)
{
return ApiResponse<CategoryDto>.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<CategoryDto>.Success(ToDto(category));
}
public async Task<ApiResponse> 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<ApiResponse<List<PlatformDto>>> 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<List<PlatformDto>>.Success(dtos);
}
public async Task<ApiResponse<PlatformDto>> 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<PlatformDto>.Success(ToDto(platform));
}
public async Task<ApiResponse<PlatformDto>> UpdatePlatformAsync(long id, CreatePlatformRequest request)
{
var platform = await _db.PlatformTypes.FindAsync(id);
if (platform == null)
{
return ApiResponse<PlatformDto>.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<PlatformDto>.Success(ToDto(platform));
}
public async Task<ApiResponse> 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<ApiResponse<List<TagDto>>> 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<List<TagDto>>.Success(tags);
}
public async Task<ApiResponse<TagDto>> 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<TagDto>.Success(ToDto(tag));
}
public async Task<ApiResponse<TagDto>> UpdateTagAsync(long id, CreateTagRequest request)
{
var tag = await _db.FeatureTags.FindAsync(id);
if (tag == null)
{
return ApiResponse<TagDto>.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<TagDto>.Success(ToDto(tag));
}
public async Task<ApiResponse> 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<ApiResponse<List<PackageDto>>> 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<List<PackageDto>>.Success(packages);
}
public async Task<ApiResponse<PackageDto>> UploadPackageAsync(long projectId, PackageType type, IFormFile file, string wakeProtocolPrefix, string wakeProtocolParam)
{
var project = await _db.Projects.FindAsync(projectId);
if (project == null)
{
return ApiResponse<PackageDto>.Fail("项目不存在", 404);
}
if (file == null || file.Length == 0)
{
return ApiResponse<PackageDto>.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<PackageDto>.Success(ToDto(package));
}
/// <summary>
/// 获取 WebGL 资源包的在线运行入口 URL
/// 查找项目下 WebGL 类型的资源包,返回解压后 index.html 的签名 URL
/// </summary>
public async Task<ApiResponse<string>> 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<string>.Fail("该项目暂无 WebGL 资源包", 404);
}
if (string.IsNullOrEmpty(pkg.ExtractedPath))
{
return ApiResponse<string>.Fail("WebGL 资源包尚未解压,请重新上传", 400);
}
// 拼接 index.html 的对象键并生成签名 URL
var indexKey = pkg.ExtractedPath.TrimEnd('/') + "/index.html";
var signedUrl = _fileStorage.GetSignedUrl(indexKey);
return ApiResponse<string>.Success(signedUrl);
}
/// <summary>
/// 获取 WebGL 代理后的 index.html 内容
/// 浏览器无法直接渲染 COS 私有桶的 index.html相对路径资源无签名参数会 403
/// 方案:后端代理读取 index.html注入 &lt;base&gt; 标签指向资源代理端点
/// 所有相对路径资源自动解析到 /api/projects/{id}/webgl-resource/{path}
/// 资源代理端点返回 302 重定向到签名 URL浏览器直接从 COS 加载
/// </summary>
public async Task<string> 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);
// 注入 <base> 标签:所有相对路径解析到资源代理端点
// 浏览器加载 index.html 后,相对路径如 Build/xxx.js 会解析为
// /api/projects/{id}/webgl-resource/Build/xxx.js
// 该端点返回 302 重定向到 COS 签名 URL浏览器直接从 COS 加载资源
var baseTag = $"<base href=\"/api/projects/{projectId}/webgl-resource/\">";
// 将 <base> 标签插入到 <head> 之后;若无 <head> 则插入到 <html> 之后;都没有则前置
if (Regex.IsMatch(html, @"<head[^>]*>", RegexOptions.IgnoreCase))
{
html = Regex.Replace(html, @"(<head[^>]*>)", $"$1{baseTag}", RegexOptions.IgnoreCase);
}
else if (Regex.IsMatch(html, @"<html[^>]*>", RegexOptions.IgnoreCase))
{
html = Regex.Replace(html, @"(<html[^>]*>)", $"$1<head>{baseTag}</head>", RegexOptions.IgnoreCase);
}
else
{
html = baseTag + html;
}
return html;
}
/// <summary>
/// 获取 WebGL 资源文件的签名 URL用于资源代理重定向
/// 前端通过 /api/projects/{id}/webgl-resource/{path} 访问资源
/// 后端生成签名 URL 后返回 302 重定向,浏览器直接从 COS 加载
/// </summary>
public async Task<string> 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);
}
/// <summary>
/// 获取视频流(防盗链代理,不暴露存储直链)
/// COS模式通过 HttpClient 流式转发Local模式直接读取本地文件均支持 Range 请求(视频拖动)
/// </summary>
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("视频不存在");
}
// 生成签名 URLLocal 模式返回相对路径如 /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<ApiResponse> 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("删除成功");
}
/// <summary>
/// 下载资源包(通过后端代理流式返回,使用原始文件名设置 Content-Disposition
/// 解决两个问题:
/// 1. 安卓端 window.open(COS签名URL) 下载失败URL 过长/弹窗被拦截)
/// 2. 下载文件名需使用原始文件名而非 COS 对象键时间戳_GUID.扩展名)
/// </summary>
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<long> 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<List<ProjectDto>> MapToDtoListAsync(List<Project> projects, long? currentUserId = null)
{
if (!projects.Any())
{
return new List<ProjectDto>();
}
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<long>();
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<string>()
: 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<string>(),
IsFavorited = favoritedIds.Contains(p.Id)
};
}).ToList();
}
private async Task<ProjectDto> MapToDtoAsync(Project project, long? currentUserId = null)
{
var list = await MapToDtoListAsync(new List<Project> { 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
};
/// <summary>
/// 限制读取范围的流包装器,使包装后的流仅暴露请求范围内的字节
/// 用于视频 Range 请求FileStreamResult 会用 stream.Length 设置 Content-Length
/// 不包装的话会返回完整文件大小,与 Content-Range 不匹配导致浏览器播放失败
/// </summary>
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<int> 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<int> ReadAsync(Memory<byte> 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<int> ReadAsyncCore(Memory<byte> 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);
}
}
}