1374 lines
50 KiB
C#
1374 lines
50 KiB
C#
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,注入 <base> 标签指向资源代理端点
|
||
/// 所有相对路径资源自动解析到 /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("视频不存在");
|
||
}
|
||
|
||
// 生成签名 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<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);
|
||
}
|
||
}
|
||
}
|