From cde2c7b9971c9c078688e9e2e495f8c9c5e2ee9a Mon Sep 17 00:00:00 2001 From: shiyu Date: Sun, 18 May 2025 20:48:20 +0800 Subject: [PATCH] Initial commit --- .dockerignore | 27 + .gitignore | 28 + BearerSecuritySchemeTransformer.cs | 45 ++ Controllers/AlbumController.cs | 195 +++++ Controllers/AuthController.cs | 173 ++++ Controllers/BackgroundTaskController.cs | 53 ++ Controllers/BaseApiController.cs | 81 ++ Controllers/ConfigController.cs | 90 +++ Controllers/PictureController.cs | 322 ++++++++ Controllers/TagController.cs | 126 +++ Dockerfile | 51 ++ Extensions/ApplicationBuilderExtensions.cs | 27 + Extensions/ServiceCollectionExtensions.cs | 120 +++ Foxel.csproj | 29 + LICENSE | 9 + Models/BaseResult.cs | 17 + Models/DataBase/Album.cs | 18 + Models/DataBase/AlbumPicture.cs | 12 + Models/DataBase/BaseModel.cs | 12 + Models/DataBase/Config.cs | 17 + Models/DataBase/Favorite.cs | 18 + Models/DataBase/Picture.cs | 69 ++ Models/DataBase/Role.cs | 15 + Models/DataBase/Tag.cs | 14 + Models/DataBase/User.cs | 26 + Models/ExifInfo.cs | 35 + Models/PaginatedResult.cs | 13 + Models/Request/Album/AlbumPictureRequest.cs | 7 + Models/Request/Album/AlbumPicturesRequest.cs | 7 + Models/Request/Album/CreateAlbumRequest.cs | 13 + Models/Request/Album/UpdateAlbumRequest.cs | 6 + Models/Request/Auth/LoginRequest.cs | 7 + Models/Request/Auth/RegisterRequest.cs | 8 + Models/Request/Config/SetConfigRequest.cs | 15 + .../Picture/DeleteMultiplePicturesRequest.cs | 6 + .../Request/Picture/DeletePictureRequest.cs | 6 + Models/Request/Picture/FavoriteRequest.cs | 6 + .../Picture/FilteredPicturesRequest.cs | 39 + .../Picture/SearchPicturesByTextRequest.cs | 9 + .../Request/Picture/UpdatePictureRequest.cs | 8 + .../Picture/UpdatePictureRequestWithId.cs | 6 + .../Request/Picture/UploadPictureRequest.cs | 13 + Models/Request/Tag/CreateTagRequest.cs | 13 + Models/Request/Tag/FilteredTagsRequest.cs | 21 + Models/Request/Tag/UpdateTagRequest.cs | 6 + Models/Response/Album/AlbumResponse.cs | 15 + Models/Response/Auth/AuthResponse.cs | 15 + Models/Response/Picture/PictureResponse.cs | 26 + Models/Response/Tag/TagResponse.cs | 19 + MyDbContext.cs | 26 + Program.cs | 39 + Properties/launchSettings.json | 23 + README.md | 91 +++ Services/AiService.cs | 434 ++++++++++ Services/AlbumService.cs | 302 +++++++ Services/BackgroundTaskQueue.cs | 454 +++++++++++ Services/ConfigService.cs | 140 ++++ Services/DatabaseInitializer.cs | 42 + Services/Interface/IAiService.cs | 35 + Services/Interface/IAlbumService.cs | 17 + Services/Interface/IBackgroundTaskQueue.cs | 51 ++ Services/Interface/IConfigService.cs | 19 + Services/Interface/IDatabaseInitializer.cs | 6 + Services/Interface/IPictureService.cs | 87 ++ Services/Interface/IStorageProvider.cs | 30 + Services/Interface/IStorageProviderFactory.cs | 14 + Services/Interface/ITagService.cs | 24 + Services/Interface/IUserService.cs | 15 + Services/PictureService.cs | 760 ++++++++++++++++++ Services/QueuedHostedService.cs | 33 + .../StorageProvider/LocalStorageProvider.cs | 48 ++ .../TelegramStorageProvider.cs | 232 ++++++ Services/StorageProviderFactory.cs | 26 + Services/TagService.cs | 233 ++++++ Services/UserService.cs | 139 ++++ Utils/AiHelper.cs | 104 +++ Utils/AuthHelper.cs | 19 + Utils/ImageHelper.cs | 345 ++++++++ appsettings.json | 12 + 79 files changed, 5713 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 BearerSecuritySchemeTransformer.cs create mode 100644 Controllers/AlbumController.cs create mode 100644 Controllers/AuthController.cs create mode 100644 Controllers/BackgroundTaskController.cs create mode 100644 Controllers/BaseApiController.cs create mode 100644 Controllers/ConfigController.cs create mode 100644 Controllers/PictureController.cs create mode 100644 Controllers/TagController.cs create mode 100644 Dockerfile create mode 100644 Extensions/ApplicationBuilderExtensions.cs create mode 100644 Extensions/ServiceCollectionExtensions.cs create mode 100644 Foxel.csproj create mode 100644 LICENSE create mode 100644 Models/BaseResult.cs create mode 100644 Models/DataBase/Album.cs create mode 100644 Models/DataBase/AlbumPicture.cs create mode 100644 Models/DataBase/BaseModel.cs create mode 100644 Models/DataBase/Config.cs create mode 100644 Models/DataBase/Favorite.cs create mode 100644 Models/DataBase/Picture.cs create mode 100644 Models/DataBase/Role.cs create mode 100644 Models/DataBase/Tag.cs create mode 100644 Models/DataBase/User.cs create mode 100644 Models/ExifInfo.cs create mode 100644 Models/PaginatedResult.cs create mode 100644 Models/Request/Album/AlbumPictureRequest.cs create mode 100644 Models/Request/Album/AlbumPicturesRequest.cs create mode 100644 Models/Request/Album/CreateAlbumRequest.cs create mode 100644 Models/Request/Album/UpdateAlbumRequest.cs create mode 100644 Models/Request/Auth/LoginRequest.cs create mode 100644 Models/Request/Auth/RegisterRequest.cs create mode 100644 Models/Request/Config/SetConfigRequest.cs create mode 100644 Models/Request/Picture/DeleteMultiplePicturesRequest.cs create mode 100644 Models/Request/Picture/DeletePictureRequest.cs create mode 100644 Models/Request/Picture/FavoriteRequest.cs create mode 100644 Models/Request/Picture/FilteredPicturesRequest.cs create mode 100644 Models/Request/Picture/SearchPicturesByTextRequest.cs create mode 100644 Models/Request/Picture/UpdatePictureRequest.cs create mode 100644 Models/Request/Picture/UpdatePictureRequestWithId.cs create mode 100644 Models/Request/Picture/UploadPictureRequest.cs create mode 100644 Models/Request/Tag/CreateTagRequest.cs create mode 100644 Models/Request/Tag/FilteredTagsRequest.cs create mode 100644 Models/Request/Tag/UpdateTagRequest.cs create mode 100644 Models/Response/Album/AlbumResponse.cs create mode 100644 Models/Response/Auth/AuthResponse.cs create mode 100644 Models/Response/Picture/PictureResponse.cs create mode 100644 Models/Response/Tag/TagResponse.cs create mode 100644 MyDbContext.cs create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 README.md create mode 100644 Services/AiService.cs create mode 100644 Services/AlbumService.cs create mode 100644 Services/BackgroundTaskQueue.cs create mode 100644 Services/ConfigService.cs create mode 100644 Services/DatabaseInitializer.cs create mode 100644 Services/Interface/IAiService.cs create mode 100644 Services/Interface/IAlbumService.cs create mode 100644 Services/Interface/IBackgroundTaskQueue.cs create mode 100644 Services/Interface/IConfigService.cs create mode 100644 Services/Interface/IDatabaseInitializer.cs create mode 100644 Services/Interface/IPictureService.cs create mode 100644 Services/Interface/IStorageProvider.cs create mode 100644 Services/Interface/IStorageProviderFactory.cs create mode 100644 Services/Interface/ITagService.cs create mode 100644 Services/Interface/IUserService.cs create mode 100644 Services/PictureService.cs create mode 100644 Services/QueuedHostedService.cs create mode 100644 Services/StorageProvider/LocalStorageProvider.cs create mode 100644 Services/StorageProvider/TelegramStorageProvider.cs create mode 100644 Services/StorageProviderFactory.cs create mode 100644 Services/TagService.cs create mode 100644 Services/UserService.cs create mode 100644 Utils/AiHelper.cs create mode 100644 Utils/AuthHelper.cs create mode 100644 Utils/ImageHelper.cs create mode 100644 appsettings.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4a94d52 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +Uploads +/View/node_modules +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f00092f --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +node_modules +dist +dist-ssr +*.local +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +/.idea +/Migrations +/bin +/obj +/Uploads +/View +/appsettings.Development.json +/Foxel.sln.DotSettings.user \ No newline at end of file diff --git a/BearerSecuritySchemeTransformer.cs b/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000..b4ff92a --- /dev/null +++ b/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Foxel; + +public sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) + : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + var requirements = new Dictionary + { + ["Bearer"] = new() + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + In = ParameterLocation.Header, + BearerFormat = "Json Web Token" + } + }; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = requirements; + + foreach (var operation in document.Paths.Values.SelectMany(path => path.Operations)) + { + operation.Value.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Id = "Bearer", Type = ReferenceType.SecurityScheme + } + }] = [] + }); + } + } + } +} \ No newline at end of file diff --git a/Controllers/AlbumController.cs b/Controllers/AlbumController.cs new file mode 100644 index 0000000..18a727a --- /dev/null +++ b/Controllers/AlbumController.cs @@ -0,0 +1,195 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foxel.Models; +using Foxel.Models.Request.Album; +using Foxel.Models.Response.Album; +using Foxel.Services.Interface; + +namespace Foxel.Controllers; + +[Authorize] +[Route("api/album")] +public class AlbumController(IAlbumService albumService) : BaseApiController +{ + [HttpGet("get_albums")] + public async Task>> GetAlbums( + [FromQuery] int page = 1, [FromQuery] int pageSize = 10) + { + var userId = GetCurrentUserId(); + try + { + var albums = await albumService.GetAlbumsAsync(page, pageSize, userId); + return PaginatedSuccess(albums.Data, albums.TotalCount, albums.Page, albums.PageSize); + } + catch (Exception ex) + { + return PaginatedError($"获取相册失败: {ex.Message}", 500); + } + } + + [HttpGet("get_album/{id}")] + public async Task>> GetAlbumById(int id) + { + try + { + var album = await albumService.GetAlbumByIdAsync(id); + return Success(album, "相册获取成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定相册", 404); + } + catch (Exception ex) + { + return Error($"获取相册失败: {ex.Message}", 500); + } + } + + [HttpPost("create_album")] + [Authorize] + public async Task>> CreateAlbum([FromBody] CreateAlbumRequest request) + { + try + { + var userId = GetCurrentUserId(); + if (userId == null) + return Error("无法识别用户信息", 401); + + var album = await albumService.CreateAlbumAsync(request.Name, request.Description, userId.Value); + return Success(album, "相册创建成功"); + } + catch (Exception ex) + { + return Error($"创建相册失败: {ex.Message}", 500); + } + } + + [HttpPost("update_album")] + [Authorize] + public async Task>> UpdateAlbum([FromBody] UpdateAlbumRequest request) + { + try + { + var currentUserId = GetCurrentUserId(); + if (currentUserId == null) + return Error("无法识别用户信息", 401); + + var album = await albumService.UpdateAlbumAsync(request.Id, request.Name, request.Description, + currentUserId); + return Success(album, "相册更新成功"); + } + catch (UnauthorizedAccessException) + { + return Error("您没有权限更新此相册", 403); + } + catch (KeyNotFoundException) + { + return Error("找不到要更新的相册", 404); + } + catch (Exception ex) + { + return Error($"更新相册失败: {ex.Message}", 500); + } + } + + [HttpPost("delete_album")] + [Authorize] + public async Task>> DeleteAlbum([FromBody] int id) + { + try + { + var currentUserId = GetCurrentUserId(); + if (currentUserId == null) + return Error("无法识别用户信息", 401); + + var result = await albumService.DeleteAlbumAsync(id); + return Success(result, "相册删除成功"); + } + catch (UnauthorizedAccessException) + { + return Error("您没有权限删除此相册", 403); + } + catch (KeyNotFoundException) + { + return Error("找不到要删除的相册", 404); + } + catch (Exception ex) + { + return Error($"删除相册失败: {ex.Message}", 500); + } + } + + [HttpPost("add_pictures")] + [Authorize] + public async Task>> AddPicturesToAlbum([FromBody] AlbumPicturesRequest request) + { + try + { + if (request.PictureIds.Count == 0) + { + return Error("未提供图片ID"); + } + + var result = await albumService.AddPicturesToAlbumAsync(request.AlbumId, request.PictureIds); + return Success(result, $"已将 {request.PictureIds.Count} 张图片添加到相册"); + } + catch (UnauthorizedAccessException) + { + return Error("您没有权限修改此相册", 403); + } + catch (KeyNotFoundException ex) + { + return Error($"添加失败: {ex.Message}", 404); + } + catch (Exception ex) + { + return Error($"添加图片到相册失败: {ex.Message}", 500); + } + } + + [HttpPost("add_picture")] + [Authorize] + public async Task>> AddPictureToAlbum([FromBody] AlbumPictureRequest request) + { + try + { + var result = await albumService.AddPictureToAlbumAsync(request.AlbumId, request.PictureId); + return Success(result, "图片已添加到相册"); + } + catch (UnauthorizedAccessException) + { + return Error("您没有权限修改此相册", 403); + } + catch (KeyNotFoundException ex) + { + return Error($"添加失败: {ex.Message}", 404); + } + catch (Exception ex) + { + return Error($"添加图片到相册失败: {ex.Message}", 500); + } + } + + [HttpPost("remove_picture")] + [Authorize] + public async Task>> RemovePictureFromAlbum([FromBody] AlbumPictureRequest request) + { + try + { + var result = await albumService.RemovePictureFromAlbumAsync(request.AlbumId, request.PictureId); + return Success(result, "图片已从相册移除"); + } + catch (UnauthorizedAccessException) + { + return Error("您没有权限修改此相册", 403); + } + catch (KeyNotFoundException ex) + { + return Error($"移除失败: {ex.Message}", 404); + } + catch (Exception ex) + { + return Error($"从相册移除图片失败: {ex.Message}", 500); + } + } +} \ No newline at end of file diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs new file mode 100644 index 0000000..7da0a82 --- /dev/null +++ b/Controllers/AuthController.cs @@ -0,0 +1,173 @@ +using System.Security.Claims; +using Foxel.Models; +using Foxel.Services.Interface; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foxel.Models.Request.Auth; +using Foxel.Models.Response.Auth; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Foxel.Controllers; + +[Route("api/auth")] +public class AuthController(IUserService userService) : BaseApiController +{ + [HttpPost("register")] + public async Task>> Register([FromBody] RegisterRequest request) + { + if (!ModelState.IsValid) + { + return Error("请求数据无效"); + } + + var (success, message, user) = await userService.RegisterUserAsync(request); + if (!success) + { + return Error(message); + } + + var token = await userService.GenerateJwtTokenAsync(user!); + var response = new AuthResponse + { + Token = token, + User = new UserProfile + { + Id = user!.Id, + UserName = user.UserName, + Email = user.Email, + RoleName = user.Role?.Name + } + }; + + return Success(response, "注册成功"); + } + + [HttpPost("login")] + public async Task>> Login([FromBody] LoginRequest request) + { + if (!ModelState.IsValid) + { + return Error("请求数据无效"); + } + + var (success, message, user) = await userService.AuthenticateUserAsync(request); + if (!success) + { + return Error(message, 401); + } + + var token = await userService.GenerateJwtTokenAsync(user!); + var response = new AuthResponse + { + Token = token, + User = new UserProfile + { + Id = user!.Id, + UserName = user.UserName, + Email = user.Email, + RoleName = user.Role?.Name + } + }; + + return Success(response, "登录成功"); + } + + [HttpGet("get_current_user")] + [Authorize] + public async Task>> GetCurrentUser() + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Error("用户ID未找到"); + } + + var user = await userService.GetUserByIdAsync(userId.Value); + if (user == null) + { + return Error("未找到用户信息", 404); + } + + var profile = new UserProfile + { + Id = userId.Value, + Email = user.Email, + UserName = user.UserName, + RoleName = user.Role?.Name + }; + + return Success(profile); + } + + [HttpGet("github/login")] + public IActionResult GitHubLogin(string returnUrl = "/") + { + try + { + var properties = new AuthenticationProperties + { + RedirectUri = Url.Action("GitHubCallback", new { returnUrl }), + Items = { { "returnUrl", returnUrl } }, + // 添加超时设置 + AllowRefresh = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10), + IsPersistent = false + }; + return Challenge(properties, "GitHub"); + } + catch (Exception ex) + { + Console.WriteLine($"GitHub登录异常: {ex}"); + return Redirect("/login?error=github_login_error"); + } + } + + [HttpGet("github/callback")] + public async Task GitHubCallback(string returnUrl = "/") + { + try + { + var authenticateResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!authenticateResult.Succeeded) + { + Console.WriteLine("GitHub认证失败: 无法获取认证结果"); + return Redirect("/login?error=github_auth_failed"); + } + // 获取GitHub用户信息 + var githubId = authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var githubLogin = authenticateResult.Principal.FindFirst("urn:github:login")?.Value; + var githubEmail = authenticateResult.Principal.FindFirst(ClaimTypes.Email)?.Value; + + Console.WriteLine($"GitHub用户信息: ID={githubId}, Login={githubLogin}, Email={githubEmail}"); + + if (string.IsNullOrEmpty(githubId) || string.IsNullOrEmpty(githubLogin)) + { + return Redirect("/login?error=github_missing_info"); + } + + // 登出Cookie认证会话 + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + // 查找或创建用户 + var (success, message, user) = await userService.FindOrCreateGitHubUserAsync( + githubId, githubLogin, githubEmail); + + if (!success || user == null) + { + return Redirect($"/login?error={Uri.EscapeDataString(message)}"); + } + + // 生成JWT令牌 + var token = await userService.GenerateJwtTokenAsync(user); + + // 重定向回前端,携带token参数 + return Redirect($"{returnUrl}?token={Uri.EscapeDataString(token)}"); + } + catch (Exception ex) + { + Console.WriteLine($"GitHub回调处理异常: {ex}"); + return Redirect("/login?error=github_callback_error"); + } + } +} \ No newline at end of file diff --git a/Controllers/BackgroundTaskController.cs b/Controllers/BackgroundTaskController.cs new file mode 100644 index 0000000..2c9c228 --- /dev/null +++ b/Controllers/BackgroundTaskController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foxel.Models; +using Foxel.Services.Interface; + +namespace Foxel.Controllers; + +[Authorize] +[Route("api/background-tasks")] +public class BackgroundTaskController : BaseApiController +{ + private readonly IBackgroundTaskQueue _backgroundTaskQueue; + + public BackgroundTaskController(IBackgroundTaskQueue backgroundTaskQueue) + { + _backgroundTaskQueue = backgroundTaskQueue; + } + + [HttpGet("user-tasks")] + public async Task>>> GetUserTasks() + { + try + { + var userId = GetCurrentUserId(); + if (userId == null) + return Error>("无法识别用户信息", 401); + + var tasks = await _backgroundTaskQueue.GetUserTasksStatusAsync(userId.Value); + return Success(tasks, "成功获取任务列表"); + } + catch (Exception ex) + { + return Error>($"获取任务状态失败: {ex.Message}", 500); + } + } + + [HttpGet("picture-status/{pictureId}")] + public async Task>> GetPictureStatus(int pictureId) + { + try + { + var status = await _backgroundTaskQueue.GetPictureProcessingStatusAsync(pictureId); + if (status == null) + return Error("找不到该图片的处理状态", 404); + + return Success(status, "成功获取图片处理状态"); + } + catch (Exception ex) + { + return Error($"获取图片处理状态失败: {ex.Message}", 500); + } + } +} diff --git a/Controllers/BaseApiController.cs b/Controllers/BaseApiController.cs new file mode 100644 index 0000000..db21f3b --- /dev/null +++ b/Controllers/BaseApiController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Mvc; +using Foxel.Models; +using System.Security.Claims; + +namespace Foxel.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public abstract class BaseApiController : ControllerBase + { + protected int? GetCurrentUserId() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return userIdClaim != null ? int.Parse(userIdClaim) : null; + } + + protected ActionResult> Success(T data, string message = "操作成功", int statusCode = 200) + { + return Ok(new BaseResult + { + Success = true, + Message = message, + Data = data, + StatusCode = statusCode + }); + } + + protected ActionResult> Success(string message = "操作成功", int statusCode = 200) + { + return Ok(new BaseResult + { + Success = true, + Message = message, + StatusCode = statusCode + }); + } + + protected ActionResult> Error(string message, int statusCode = 400) + { + return StatusCode(statusCode, new BaseResult + { + Success = false, + Message = message, + StatusCode = statusCode + }); + } + + protected ActionResult> PaginatedSuccess( + List? data, + int totalCount, + int page, + int pageSize, + string message = "获取成功") + { + return Ok(new PaginatedResult + { + Success = true, + Message = message, + Data = data, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + StatusCode = 200 + }); + } + + protected ActionResult> PaginatedError(string message, int statusCode = 400) + { + return StatusCode(statusCode, new PaginatedResult + { + Success = false, + Message = message, + Data = new List(), + TotalCount = 0, + Page = 0, + PageSize = 0, + StatusCode = statusCode + }); + } + } +} \ No newline at end of file diff --git a/Controllers/ConfigController.cs b/Controllers/ConfigController.cs new file mode 100644 index 0000000..b0cced0 --- /dev/null +++ b/Controllers/ConfigController.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Models.Request.Config; +using Foxel.Services.Interface; + +namespace Foxel.Controllers; + +[Authorize(Roles = "Administrator")] +[Route("api/config")] +public class ConfigController(IConfigService configService) : BaseApiController +{ + [HttpGet("get_configs")] + public async Task>>> GetConfigs() + { + try + { + var configs = await configService.GetAllConfigsAsync(); + return Success(configs, "获取所有配置成功"); + } + catch (Exception ex) + { + return Error>($"获取配置失败: {ex.Message}", 500); + } + } + + [HttpGet("get_config/{key}")] + public async Task>> GetConfig(string key) + { + try + { + if (string.IsNullOrWhiteSpace(key)) + return Error("配置键不能为空"); + + var config = await configService.GetConfigAsync(key); + + if (config == null) + return Error($"找不到键为 '{key}' 的配置", 404); + + return Success(config, "获取配置成功"); + } + catch (Exception ex) + { + return Error($"获取配置失败: {ex.Message}", 500); + } + } + + [HttpPost("set_config")] + public async Task>> SetConfig([FromBody] SetConfigRequest request) + { + try + { + if (string.IsNullOrWhiteSpace(request.Key)) + return Error("配置键不能为空"); + + var config = await configService.SetConfigAsync( + request.Key.Trim(), + request.Value ?? string.Empty, + request.Description); + + return Success(config, "配置设置成功"); + } + catch (Exception ex) + { + return Error($"设置配置失败: {ex.Message}", 500); + } + } + + [HttpPost("delete_config")] + public async Task>> DeleteConfig([FromBody] string key) + { + try + { + if (string.IsNullOrWhiteSpace(key)) + return Error("配置键不能为空"); + + var result = await configService.DeleteConfigAsync(key); + + if (!result) + return Error($"找不到键为 '{key}' 的配置", 404); + + return Success(true, $"成功删除键为 '{key}' 的配置"); + } + catch (Exception ex) + { + return Error($"删除配置失败: {ex.Message}", 500); + } + } +} \ No newline at end of file diff --git a/Controllers/PictureController.cs b/Controllers/PictureController.cs new file mode 100644 index 0000000..93ba674 --- /dev/null +++ b/Controllers/PictureController.cs @@ -0,0 +1,322 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Models.Request.Picture; +using Foxel.Models.Response.Picture; +using Foxel.Services.Interface; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Foxel.Controllers; + +[Authorize] +[Route("api/picture")] +public class PictureController(IPictureService pictureService,IConfigService configService) : BaseApiController +{ + [HttpGet("get_pictures")] + public async Task>> GetPictures( + [FromQuery] FilteredPicturesRequest request) + { + try + { + List? tagsList = null; + if (!string.IsNullOrWhiteSpace(request.Tags)) + { + tagsList = request.Tags.Split(',') + .Select(t => t.Trim()) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToList(); + } + + var currentUserId = GetCurrentUserId(); + + var result = await pictureService.GetPicturesAsync( + request.Page, + request.PageSize, + request.SearchQuery, + tagsList, + request.StartDate, + request.EndDate, + currentUserId, + request.SortBy, + request.OnlyWithGps, + request.UseVectorSearch, + request.SimilarityThreshold, + request.ExcludeAlbumId, + request.AlbumId, + request.OnlyFavorites, + request.OwnerId, + request.IncludeAllPublic + ); + + return PaginatedSuccess(result.Data, result.TotalCount, result.Page, result.PageSize); + } + catch (Exception ex) + { + return PaginatedError($"获取图片失败: {ex.Message}", 500); + } + } + + [AllowAnonymous] + [HttpPost("upload_picture")] + [Consumes("multipart/form-data")] + public async Task>> UploadPicture( + [FromForm] UploadPictureRequest request) + { + if (request.File.Length == 0) + return Error("没有上传文件"); + + try + { + var userId = GetCurrentUserId(); + + await using var stream = request.File.OpenReadStream(); + var result = await pictureService.UploadPictureAsync( + request.File.FileName, + stream, + request.File.ContentType, + userId, + (PermissionType)request.Permission!, + request.AlbumId + ); + + var picture = result.Picture; + + return Success(picture, "图片上传成功"); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (Exception ex) + { + return Error($"上传图片失败: {ex.Message}", 500); + } + } + + [HttpPost("delete_pictures")] + public async Task>> DeleteMultiplePictures( + [FromBody] DeleteMultiplePicturesRequest request) + { + try + { + var currentUserId = GetCurrentUserId(); + if (currentUserId == null) + return Error("无法识别用户信息"); + + if (!request.PictureIds.Any()) + return Error("未提供要删除的图片ID"); + + // 获取删除结果 + var results = await pictureService.DeleteMultiplePicturesAsync(request.PictureIds); + + // 权限验证和处理结果 + var unauthorizedIds = new List(); + var notFoundIds = new List(); + var successIds = new List(); + var errors = new Dictionary(); + + foreach (var (pictureId, (success, errorMessage, ownerId)) in results) + { + // 检查权限 + if (ownerId.HasValue && ownerId.Value != currentUserId.Value) + { + unauthorizedIds.Add(pictureId); + continue; + } + + if (!success) + { + notFoundIds.Add(pictureId); + } + else if (!string.IsNullOrEmpty(errorMessage)) + { + errors[pictureId] = errorMessage; + } + else + { + successIds.Add(pictureId); + } + } + + // 如果有未授权或其他错误,返回适当的响应 + if (unauthorizedIds.Any() || notFoundIds.Any() || errors.Any()) + { + var messages = new List(); + + if (unauthorizedIds.Any()) + messages.Add($"无权删除以下图片: {string.Join(", ", unauthorizedIds)}"); + + if (notFoundIds.Any()) + messages.Add($"找不到以下图片: {string.Join(", ", notFoundIds)}"); + + if (errors.Any()) + messages.Add(string.Join("; ", errors.Select(e => $"图片ID {e.Key}: {e.Value}"))); + + return StatusCode(207, new BaseResult + { + Success = successIds.Any(), + Message = string.Join("; ", messages), + StatusCode = 207, + Data = new + { + SuccessCount = successIds.Count, + SuccessIds = successIds, + UnauthorizedIds = unauthorizedIds, + NotFoundIds = notFoundIds, + Errors = errors + } + }); + } + + return Success($"成功删除 {successIds.Count} 张图片"); + } + catch (Exception ex) + { + return Error($"删除图片失败: {ex.Message}", 500); + } + } + + [HttpPost("update_picture")] + public async Task>> UpdatePicture( + [FromBody] UpdatePictureRequestWithId request) + { + try + { + var currentUserId = GetCurrentUserId(); + if (currentUserId == null) + return Error("无法识别用户信息"); + + var (picture, ownerId) = await pictureService.UpdatePictureAsync( + request.Id, request.Name, request.Description, request.Tags); + + // 权限验证 + if (ownerId.HasValue && ownerId.Value != currentUserId.Value) + { + return Error("您没有权限更新此图片", 403); + } + + return Success(picture, "图片信息已成功更新"); + } + catch (KeyNotFoundException) + { + return Error("找不到要更新的图片", 404); + } + catch (Exception ex) + { + return Error($"更新图片失败: {ex.Message}", 500); + } + } + + [HttpPost("favorite")] + public async Task>> FavoritePicture([FromBody] FavoriteRequest request) + { + try + { + var userId = GetCurrentUserId(); + if (userId == null) + return Error("无法识别用户信息", 401); + + var result = await pictureService.FavoritePictureAsync(request.PictureId, userId.Value); + return Success(result, "图片收藏成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定图片", 404); + } + catch (InvalidOperationException ex) + { + return Error(ex.Message); + } + catch (Exception ex) + { + return Error($"收藏图片失败: {ex.Message}", 500); + } + } + + [HttpPost("unfavorite")] + public async Task>> UnfavoritePicture([FromBody] FavoriteRequest request) + { + try + { + var userId = GetCurrentUserId(); + if (userId == null) + return Error("无法识别用户信息", 401); + + var result = await pictureService.UnfavoritePictureAsync(request.PictureId, userId.Value); + return Success(result, "已取消收藏"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定图片或收藏记录", 404); + } + catch (Exception ex) + { + return Error($"取消收藏失败: {ex.Message}", 500); + } + } + + [HttpGet("get_telegram_file")] + [AllowAnonymous] + public async Task GetTelegramFile([FromQuery] string fileId) + { + try + { + var botToken = configService["Storage:TelegramStorageBotToken"]; + if (string.IsNullOrEmpty(botToken)) + return BadRequest("Telegram Bot Token 未配置"); + + using var httpClient = new HttpClient(); + var getFileUrl = $"https://api.telegram.org/bot{botToken}/getFile?file_id={fileId}"; + var getFileResponse = await httpClient.GetAsync(getFileUrl); + + if (!getFileResponse.IsSuccessStatusCode) + { + var errorContent = await getFileResponse.Content.ReadAsStringAsync(); + return StatusCode((int)getFileResponse.StatusCode, $"获取文件路径失败: {errorContent}"); + } + + var getFileContent = await getFileResponse.Content.ReadAsStringAsync(); + var getFileResult = JsonSerializer.Deserialize(getFileContent); + if (getFileResult == null || !getFileResult.Ok || string.IsNullOrEmpty(getFileResult.Result?.FilePath)) + { + return BadRequest("无法解析 Telegram 文件路径"); + } + + var filePath = getFileResult.Result.FilePath; + var fileUrl = $"https://api.telegram.org/file/bot{botToken}/{filePath}"; + + var fileResponse = await httpClient.GetAsync(fileUrl); + if (!fileResponse.IsSuccessStatusCode) + { + return StatusCode((int)fileResponse.StatusCode, "下载文件失败"); + } + + var contentType = fileResponse.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; + var fileStream = await fileResponse.Content.ReadAsStreamAsync(); + + return File(fileStream, contentType); + } + catch (Exception ex) + { + return StatusCode(500, $"代理获取文件失败: {ex.Message}"); + } + } + + // 用于解析 Telegram getFile API 响应的辅助类 + private class TelegramGetFileResponse + { + [JsonPropertyName("ok")] + public bool Ok { get; set; } + + [JsonPropertyName("result")] + public TelegramFileResult? Result { get; set; } + } + + private class TelegramFileResult + { + [JsonPropertyName("file_path")] + public string? FilePath { get; set; } + } +} \ No newline at end of file diff --git a/Controllers/TagController.cs b/Controllers/TagController.cs new file mode 100644 index 0000000..21004e6 --- /dev/null +++ b/Controllers/TagController.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foxel.Models; +using Foxel.Models.Request.Tag; +using Foxel.Models.Response.Tag; +using Foxel.Services.Interface; + +namespace Foxel.Controllers; + +[Route("api/tag")] +public class TagController(ITagService tagService) : BaseApiController +{ + [HttpGet("get_tags")] + public async Task>> GetFilteredTags([FromQuery] FilteredTagsRequest request) + { + try + { + var result = await tagService.GetFilteredTagsAsync( + request.Page, + request.PageSize, + request.SearchQuery, + request.SortBy, + request.SortDirection + ); + + return PaginatedSuccess(result.Data, result.TotalCount, result.Page, result.PageSize); + } + catch (Exception ex) + { + return PaginatedError($"获取标签失败: {ex.Message}", 500); + } + } + + // 添加一个简化的获取所有标签的方法,内部使用GetFilteredTagsAsync + [HttpGet("all")] + public async Task>>> GetAllTags() + { + try + { + // 使用过滤方法但获取更多数据 + var result = await tagService.GetFilteredTagsAsync( + page: 1, + pageSize: 1000, // 设置一个较大值获取所有标签 + sortBy: "pictureCount", + sortDirection: "desc" + ); + + return Success(result.Data, "标签获取成功"); + } + catch (Exception ex) + { + return Error>($"获取标签失败: {ex.Message}", 500); + } + } + + [HttpGet("{id}")] + public async Task>> GetTagById(int id) + { + try + { + var tag = await tagService.GetTagByIdAsync(id); + return Success(tag, "标签获取成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定标签", 404); + } + catch (Exception ex) + { + return Error($"获取标签失败: {ex.Message}", 500); + } + } + + [HttpPost("create_tag")] + [Authorize] + public async Task>> CreateTag([FromBody] CreateTagRequest request) + { + try + { + var tag = await tagService.CreateTagAsync(request.Name, request.Description); + return Success(tag, "标签创建成功"); + } + catch (Exception ex) + { + return Error($"创建标签失败: {ex.Message}", 500); + } + } + + [HttpPost("update_tag")] + [Authorize] + public async Task>> UpdateTag([FromBody] UpdateTagRequest request) + { + try + { + var tag = await tagService.UpdateTagAsync(request.Id, request.Name, request.Description); + return Success(tag, "标签更新成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到要更新的标签", 404); + } + catch (Exception ex) + { + return Error($"更新标签失败: {ex.Message}", 500); + } + } + + [HttpPost("delete_tag")] + [Authorize] + public async Task>> DeleteTag([FromBody] int id) + { + try + { + var result = await tagService.DeleteTagAsync(id); + return Success(result, "标签删除成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到要删除的标签", 404); + } + catch (Exception ex) + { + return Error($"删除标签失败: {ex.Message}", 500); + } + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc6c8d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 8080 +EXPOSE 80 + +FROM oven/bun:alpine AS build-frontend +WORKDIR /src/View +COPY View/package*.json ./ +RUN bun install +COPY View/ ./ +RUN bun run build + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Foxel.csproj", "./"] +RUN dotnet restore "Foxel.csproj" +COPY . . +WORKDIR "/src/" +RUN dotnet build "./Foxel.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Foxel.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +ENV DEFAULT_CONNECTION="YourDefaultConnectionStringHere" +COPY --from=publish /app/publish . +RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/* +COPY --from=build-frontend /src/View/dist /var/www/html +COPY /View/nginx.conf /etc/nginx/nginx.conf + +RUN mkdir -p /var/lib/nginx/body /var/cache/nginx /var/run/nginx \ + && chown -R $APP_UID:$APP_UID /var/lib/nginx /var/cache/nginx /var/run/nginx /var/log/nginx /etc/nginx /var/www/html \ + && mkdir -p /run \ + && chmod 777 /run + +RUN echo '#!/bin/bash\n\ + # 启动nginx\n\ + nginx -g "daemon off;" &\n\ + \n\ + # 启动.NET应用程序\n\ + dotnet Foxel.dll\n\ + ' > /start.sh && chmod +x /start.sh + +RUN mkdir -p /home/app/.aspnet/DataProtection-Keys \ + && chown -R $APP_UID:$APP_UID /home/app/.aspnet + +USER $APP_UID +ENTRYPOINT ["/start.sh"] \ No newline at end of file diff --git a/Extensions/ApplicationBuilderExtensions.cs b/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..237593e --- /dev/null +++ b/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.FileProviders; +using Scalar.AspNetCore; + +namespace Foxel.Extensions; + +public static class ApplicationBuilderExtensions +{ + public static void UseApplicationStaticFiles(this WebApplication app) + { + var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads"); + if (!Directory.Exists(uploadsPath)) + { + Directory.CreateDirectory(uploadsPath); + } + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(uploadsPath), + RequestPath = "/uploads" + }); + } + + public static void UseApplicationOpenApi(this WebApplication app) + { + app.MapOpenApi(); + } +} \ No newline at end of file diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b20dd10 --- /dev/null +++ b/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,120 @@ +using Foxel.Services; +using Foxel.Services.Interface; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Foxel.Services.StorageProvider; + +namespace Foxel.Extensions; + +public static class ServiceCollectionExtensions +{ + public static void AddCoreServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public static void AddApplicationDbContext(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrEmpty(connectionString)) + { + connectionString = Environment.GetEnvironmentVariable("DEFAULT_CONNECTION"); + } + + Console.WriteLine($"数据库连接: {connectionString}"); + services.AddDbContextFactory(options => + options.UseNpgsql(connectionString, o => o.UseVector())); + } + + public static void AddApplicationOpenApi(this IServiceCollection services) + { + services.AddOpenApi(opt => { opt.AddDocumentTransformer(); }); + } + + public static void AddApplicationAuthentication(this IServiceCollection services) + { + IConfigService configuration = services.BuildServiceProvider().GetRequiredService(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration["Jwt:Issuer"], + ValidAudience = configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"])) + }; + }) + .AddCookie(options => + { + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromMinutes(60); + }) + .AddGitHub(options => + { + options.ClientId = configuration["Authentication:GitHubClientId"]; + options.ClientSecret = configuration["Authentication:GitHubClientSecret"]; + options.CallbackPath = "/api/auth/github/callback"; + options.Scope.Add("user:email"); + options.BackchannelHttpHandler = new HttpClientHandler + { + UseCookies = false, + AllowAutoRedirect = false, + MaxConnectionsPerServer = 100 + }; + options.Events = new Microsoft.AspNetCore.Authentication.OAuth.OAuthEvents + { + OnRemoteFailure = context => + { + Console.WriteLine($"GitHub登录失败: {context.Failure}"); + context.Response.Redirect("/login?error=github_remote_error"); + context.HandleResponse(); + return Task.CompletedTask; + } + }; + }); + } + + public static void AddApplicationAuthorization(this IServiceCollection services) + { + services.AddAuthorization(options => + { + options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + }); + } + + public static void AddApplicationCors(this IServiceCollection services) + { + services.AddCors(options => + { + options.AddPolicy(name: "MyAllowSpecificOrigins", + policy => { policy.WithOrigins().AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); }); + }); + } +} \ No newline at end of file diff --git a/Foxel.csproj b/Foxel.csproj new file mode 100644 index 0000000..3240bec --- /dev/null +++ b/Foxel.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86d13a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright © 2025 Foxel + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/Models/BaseResult.cs b/Models/BaseResult.cs new file mode 100644 index 0000000..27df600 --- /dev/null +++ b/Models/BaseResult.cs @@ -0,0 +1,17 @@ +namespace Foxel.Models; + +public class BaseResult +{ + public string Message { get; set; } = string.Empty; + public bool Success { get; set; } = true; + public T? Data { get; set; } + public int StatusCode { get; set; } = 200; +} + +public class BaseResult +{ + public string Message { get; set; } = string.Empty; + public bool Success { get; set; } = true; + public int Data { get; set; } + public int StatusCode { get; set; } = 200; +} \ No newline at end of file diff --git a/Models/DataBase/Album.cs b/Models/DataBase/Album.cs new file mode 100644 index 0000000..61e0b1a --- /dev/null +++ b/Models/DataBase/Album.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.DataBase; + +public class Album : BaseModel +{ + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [StringLength(500)] + public string Description { get; set; } = string.Empty; + public int UserId { get; set; } + [Required] + public User User { get; set; } + + public ICollection? Pictures { get; set; } +} diff --git a/Models/DataBase/AlbumPicture.cs b/Models/DataBase/AlbumPicture.cs new file mode 100644 index 0000000..325b7bd --- /dev/null +++ b/Models/DataBase/AlbumPicture.cs @@ -0,0 +1,12 @@ +namespace Foxel.Models.DataBase; + +public class AlbumPicture +{ + public int Id { get; set; } + public int AlbumId { get; set; } + public int PictureId { get; set; } + + // 导航属性 + public Album? Album { get; set; } + public Picture? Picture { get; set; } +} diff --git a/Models/DataBase/BaseModel.cs b/Models/DataBase/BaseModel.cs new file mode 100644 index 0000000..11f69b0 --- /dev/null +++ b/Models/DataBase/BaseModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.DataBase; + +public abstract class BaseModel +{ + [Key] public int Id { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Models/DataBase/Config.cs b/Models/DataBase/Config.cs new file mode 100644 index 0000000..f18c5c1 --- /dev/null +++ b/Models/DataBase/Config.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.DataBase; + +public class Config : BaseModel +{ + [Required] + [StringLength(50)] + public string Key { get; set; } = string.Empty; + + [Required] + [StringLength(255)] + public string Value { get; set; } = string.Empty; + + [StringLength(255)] + public string Description { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Models/DataBase/Favorite.cs b/Models/DataBase/Favorite.cs new file mode 100644 index 0000000..cc53125 --- /dev/null +++ b/Models/DataBase/Favorite.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Foxel.Models.DataBase; + +public class Favorite +{ + [Key] + public int Id { get; set; } + + public User User { get; set; } + + public int PictureId { get; set; } + [ForeignKey("PictureId")] + public Picture Picture { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Models/DataBase/Picture.cs b/Models/DataBase/Picture.cs new file mode 100644 index 0000000..d333644 --- /dev/null +++ b/Models/DataBase/Picture.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using Vector = Pgvector.Vector; + +namespace Foxel.Models.DataBase; + +public enum StorageType +{ + Local = 0, + Telegram = 1, +} + +public class Picture : BaseModel +{ + [StringLength(255)] public string Name { get; set; } = string.Empty; + + [StringLength(1024)] public string Path { get; set; } = string.Empty; + + [StringLength(1024)] public string? ThumbnailPath { get; set; } = string.Empty; + + [StringLength(2000)] public string Description { get; set; } = string.Empty; + [Column(TypeName = "vector(1024)")] public Vector? Embedding { get; set; } + + public DateTime? TakenAt { get; set; } + + [Column(TypeName = "jsonb")] public string? ExifInfoJson { get; set; } + + [NotMapped] + public ExifInfo? ExifInfo + { + get => ExifInfoJson != null ? JsonSerializer.Deserialize(ExifInfoJson) : null; + set => ExifInfoJson = value != null ? JsonSerializer.Serialize(value) : null; + } + + public StorageType StorageType { get; set; } = StorageType.Local; + + public ICollection? Tags { get; set; } + public int? UserId { get; set; } + + public User? User { get; set; } + + public int? AlbumId { get; set; } + public Album? Album { get; set; } + + public ICollection? Favorites { get; set; } + + public bool ContentWarning { get; set; } = false; + public PermissionType Permission { get; set; } = PermissionType.Public; + + public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; + public string? ProcessingError { get; set; } + public int ProcessingProgress { get; set; } = 0; +} + +public enum PermissionType +{ + Public = 0, + Friends = 1, + Private = 2 +} + +public enum ProcessingStatus +{ + Pending, // 等待处理 + Processing, // 处理中 + Completed, // 处理完成 + Failed // 处理失败 +} \ No newline at end of file diff --git a/Models/DataBase/Role.cs b/Models/DataBase/Role.cs new file mode 100644 index 0000000..ce8984d --- /dev/null +++ b/Models/DataBase/Role.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.DataBase; + +public class Role : BaseModel +{ + [Required] + [StringLength(50)] + public string Name { get; set; } = string.Empty; + + [StringLength(200)] + public string Description { get; set; } = string.Empty; + + public ICollection? Users { get; set; } +} \ No newline at end of file diff --git a/Models/DataBase/Tag.cs b/Models/DataBase/Tag.cs new file mode 100644 index 0000000..e0086aa --- /dev/null +++ b/Models/DataBase/Tag.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.DataBase; + +public class Tag : BaseModel +{ + [Required] [StringLength(50)] public string Name { get; set; } = string.Empty; + + [StringLength(200)] public string Description { get; set; } = string.Empty; + + public ICollection? Pictures { get; set; } + + public ICollection? Users { get; set; } +} \ No newline at end of file diff --git a/Models/DataBase/User.cs b/Models/DataBase/User.cs new file mode 100644 index 0000000..6da5feb --- /dev/null +++ b/Models/DataBase/User.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.DataBase; + +public class User : BaseModel +{ + [Required] [StringLength(50)] public required string UserName { get; set; } + + [Required] + [EmailAddress] + [StringLength(100)] + public required string Email { get; set; } + + [Required] [StringLength(255)] public required string PasswordHash { get; set; } + + [StringLength(255)] public string? GithubId { get; set; } + public int? RoleId { get; set; } + + public Role? Role { get; set; } + + public ICollection? Favorites { get; set; } + + public ICollection? Tags { get; set; } + + public ICollection? Albums { get; set; } +} \ No newline at end of file diff --git a/Models/ExifInfo.cs b/Models/ExifInfo.cs new file mode 100644 index 0000000..92741cc --- /dev/null +++ b/Models/ExifInfo.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Foxel.Models; + +public class ExifInfo +{ + // 基本图像信息 + public int Width { get; set; } + public int Height { get; set; } + + // 相机信息 + public string? CameraMaker { get; set; } + public string? CameraModel { get; set; } + public string? Software { get; set; } + + // 拍摄参数 + public string? ExposureTime { get; set; } + public string? Aperture { get; set; } + public string? IsoSpeed { get; set; } + public string? FocalLength { get; set; } + public string? Flash { get; set; } + public string? MeteringMode { get; set; } + public string? WhiteBalance { get; set; } + + // 时间信息 + public string? DateTimeOriginal { get; set; } + + // 位置信息 + public string? GpsLatitude { get; set; } + public string? GpsLongitude { get; set; } + + // 错误信息 + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorMessage { get; set; } +} diff --git a/Models/PaginatedResult.cs b/Models/PaginatedResult.cs new file mode 100644 index 0000000..3b9147e --- /dev/null +++ b/Models/PaginatedResult.cs @@ -0,0 +1,13 @@ +namespace Foxel.Models; + +public class PaginatedResult : BaseResult> +{ + + + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 10; + public int TotalCount { get; set; } + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + public bool HasPreviousPage => Page > 1; + public bool HasNextPage => Page < TotalPages; +} \ No newline at end of file diff --git a/Models/Request/Album/AlbumPictureRequest.cs b/Models/Request/Album/AlbumPictureRequest.cs new file mode 100644 index 0000000..75c77e4 --- /dev/null +++ b/Models/Request/Album/AlbumPictureRequest.cs @@ -0,0 +1,7 @@ +namespace Foxel.Models.Request.Album; + +public class AlbumPictureRequest +{ + public int AlbumId { get; set; } + public int PictureId { get; set; } +} diff --git a/Models/Request/Album/AlbumPicturesRequest.cs b/Models/Request/Album/AlbumPicturesRequest.cs new file mode 100644 index 0000000..5568a7f --- /dev/null +++ b/Models/Request/Album/AlbumPicturesRequest.cs @@ -0,0 +1,7 @@ +namespace Foxel.Models.Request.Album; + +public class AlbumPicturesRequest +{ + public int AlbumId { get; set; } + public List PictureIds { get; set; } = new(); +} diff --git a/Models/Request/Album/CreateAlbumRequest.cs b/Models/Request/Album/CreateAlbumRequest.cs new file mode 100644 index 0000000..f8cf0b2 --- /dev/null +++ b/Models/Request/Album/CreateAlbumRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.Request.Album; + +public class CreateAlbumRequest +{ + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [StringLength(500)] + public string? Description { get; set; } +} diff --git a/Models/Request/Album/UpdateAlbumRequest.cs b/Models/Request/Album/UpdateAlbumRequest.cs new file mode 100644 index 0000000..0d50393 --- /dev/null +++ b/Models/Request/Album/UpdateAlbumRequest.cs @@ -0,0 +1,6 @@ +namespace Foxel.Models.Request.Album; + +public class UpdateAlbumRequest : CreateAlbumRequest +{ + public int Id { get; set; } +} diff --git a/Models/Request/Auth/LoginRequest.cs b/Models/Request/Auth/LoginRequest.cs new file mode 100644 index 0000000..f5bb801 --- /dev/null +++ b/Models/Request/Auth/LoginRequest.cs @@ -0,0 +1,7 @@ +namespace Foxel.Models.Request.Auth; + +public class LoginRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/Models/Request/Auth/RegisterRequest.cs b/Models/Request/Auth/RegisterRequest.cs new file mode 100644 index 0000000..480e18e --- /dev/null +++ b/Models/Request/Auth/RegisterRequest.cs @@ -0,0 +1,8 @@ +namespace Foxel.Models.Request.Auth; + +public class RegisterRequest +{ + public string UserName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/Models/Request/Config/SetConfigRequest.cs b/Models/Request/Config/SetConfigRequest.cs new file mode 100644 index 0000000..58b12c4 --- /dev/null +++ b/Models/Request/Config/SetConfigRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.Request.Config; + +public class SetConfigRequest +{ + [Required(ErrorMessage = "配置键不能为空")] + [StringLength(50, ErrorMessage = "配置键长度不能超过50个字符")] + public string Key { get; set; } = string.Empty; + + public string? Value { get; set; } + + [StringLength(255, ErrorMessage = "描述长度不能超过255个字符")] + public string? Description { get; set; } +} diff --git a/Models/Request/Picture/DeleteMultiplePicturesRequest.cs b/Models/Request/Picture/DeleteMultiplePicturesRequest.cs new file mode 100644 index 0000000..e00f3fd --- /dev/null +++ b/Models/Request/Picture/DeleteMultiplePicturesRequest.cs @@ -0,0 +1,6 @@ +namespace Foxel.Models.Request.Picture; + +public class DeleteMultiplePicturesRequest +{ + public List PictureIds { get; set; } = new(); +} diff --git a/Models/Request/Picture/DeletePictureRequest.cs b/Models/Request/Picture/DeletePictureRequest.cs new file mode 100644 index 0000000..9da45c4 --- /dev/null +++ b/Models/Request/Picture/DeletePictureRequest.cs @@ -0,0 +1,6 @@ +namespace Foxel.Models.Request.Picture; + +public class DeletePictureRequest +{ + public int Id { get; set; } +} diff --git a/Models/Request/Picture/FavoriteRequest.cs b/Models/Request/Picture/FavoriteRequest.cs new file mode 100644 index 0000000..b5b7dae --- /dev/null +++ b/Models/Request/Picture/FavoriteRequest.cs @@ -0,0 +1,6 @@ +namespace Foxel.Models.Request.Picture; + +public class FavoriteRequest +{ + public int PictureId { get; set; } +} diff --git a/Models/Request/Picture/FilteredPicturesRequest.cs b/Models/Request/Picture/FilteredPicturesRequest.cs new file mode 100644 index 0000000..759e8a6 --- /dev/null +++ b/Models/Request/Picture/FilteredPicturesRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.Request.Picture +{ + public class FilteredPicturesRequest + { + [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] + public int Page { get; set; } = 1; + + [Range(1, 100, ErrorMessage = "每页数量必须在1-100之间")] + public int PageSize { get; set; } = 8; + + public string? SearchQuery { get; set; } + + public string? Tags { get; set; } + + public DateTime? StartDate { get; set; } + + public DateTime? EndDate { get; set; } + + public string? SortBy { get; set; } = "newest"; + + public bool OnlyWithGps { get; set; } = false; + + public bool UseVectorSearch { get; set; } = false; + + public double SimilarityThreshold { get; set; } = 0.36; + + public int? ExcludeAlbumId { get; set; } + + public int? AlbumId { get; set; } + + public bool OnlyFavorites { get; set; } = false; + + public int? OwnerId { get; set; } + + public bool IncludeAllPublic { get; set; } = false; + } +} \ No newline at end of file diff --git a/Models/Request/Picture/SearchPicturesByTextRequest.cs b/Models/Request/Picture/SearchPicturesByTextRequest.cs new file mode 100644 index 0000000..14a8f29 --- /dev/null +++ b/Models/Request/Picture/SearchPicturesByTextRequest.cs @@ -0,0 +1,9 @@ +namespace Foxel.Models.Request.Picture; + +public class SearchPicturesByTextRequest +{ + public string Query { get; set; } = string.Empty; + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 8; + public double SimilarityThreshold { get; set; } = 0.36; +} diff --git a/Models/Request/Picture/UpdatePictureRequest.cs b/Models/Request/Picture/UpdatePictureRequest.cs new file mode 100644 index 0000000..16abec1 --- /dev/null +++ b/Models/Request/Picture/UpdatePictureRequest.cs @@ -0,0 +1,8 @@ +namespace Foxel.Models.Request.Picture; + +public class UpdatePictureRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public List? Tags { get; set; } +} diff --git a/Models/Request/Picture/UpdatePictureRequestWithId.cs b/Models/Request/Picture/UpdatePictureRequestWithId.cs new file mode 100644 index 0000000..b0cd6c0 --- /dev/null +++ b/Models/Request/Picture/UpdatePictureRequestWithId.cs @@ -0,0 +1,6 @@ +namespace Foxel.Models.Request.Picture; + +public class UpdatePictureRequestWithId : UpdatePictureRequest +{ + public int Id { get; set; } +} diff --git a/Models/Request/Picture/UploadPictureRequest.cs b/Models/Request/Picture/UploadPictureRequest.cs new file mode 100644 index 0000000..d184207 --- /dev/null +++ b/Models/Request/Picture/UploadPictureRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using Foxel.Models.DataBase; + +namespace Foxel.Models.Request.Picture; + +public class UploadPictureRequest +{ + [Required] public IFormFile File { get; set; } = null!; + + public int? Permission { get; set; } = 1; + + public int? AlbumId { get; set; } = null; +} \ No newline at end of file diff --git a/Models/Request/Tag/CreateTagRequest.cs b/Models/Request/Tag/CreateTagRequest.cs new file mode 100644 index 0000000..1b48c07 --- /dev/null +++ b/Models/Request/Tag/CreateTagRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.Request.Tag; + +public class CreateTagRequest +{ + [Required] + [StringLength(50)] + public string Name { get; set; } = string.Empty; + + [StringLength(200)] + public string? Description { get; set; } +} diff --git a/Models/Request/Tag/FilteredTagsRequest.cs b/Models/Request/Tag/FilteredTagsRequest.cs new file mode 100644 index 0000000..141b805 --- /dev/null +++ b/Models/Request/Tag/FilteredTagsRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Foxel.Models.Request.Tag +{ + public class FilteredTagsRequest + { + [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] + public int Page { get; set; } = 1; + + [Range(1, 100, ErrorMessage = "每页数量必须在1-100之间")] + public int PageSize { get; set; } = 20; + + public string? SearchQuery { get; set; } + + // 排序方式:name, pictureCount, createdAt + public string? SortBy { get; set; } = "pictureCount"; + + // 排序方向:asc, desc + public string? SortDirection { get; set; } = "desc"; + } +} diff --git a/Models/Request/Tag/UpdateTagRequest.cs b/Models/Request/Tag/UpdateTagRequest.cs new file mode 100644 index 0000000..ce98078 --- /dev/null +++ b/Models/Request/Tag/UpdateTagRequest.cs @@ -0,0 +1,6 @@ +namespace Foxel.Models.Request.Tag; + +public class UpdateTagRequest : CreateTagRequest +{ + public int Id { get; set; } +} diff --git a/Models/Response/Album/AlbumResponse.cs b/Models/Response/Album/AlbumResponse.cs new file mode 100644 index 0000000..9ae6972 --- /dev/null +++ b/Models/Response/Album/AlbumResponse.cs @@ -0,0 +1,15 @@ +using Foxel.Models.Response.Picture; + +namespace Foxel.Models.Response.Album; + +public class AlbumResponse +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int PictureCount { get; set; } = 0; + public int UserId { get; set; } + public string? Username { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Models/Response/Auth/AuthResponse.cs b/Models/Response/Auth/AuthResponse.cs new file mode 100644 index 0000000..d164f55 --- /dev/null +++ b/Models/Response/Auth/AuthResponse.cs @@ -0,0 +1,15 @@ +namespace Foxel.Models.Response.Auth; + +public class UserProfile +{ + public int Id { get; set; } + public string UserName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string? RoleName { get; set; } +} + +public class AuthResponse +{ + public string Token { get; set; } = string.Empty; + public UserProfile User { get; set; } = new(); +} diff --git a/Models/Response/Picture/PictureResponse.cs b/Models/Response/Picture/PictureResponse.cs new file mode 100644 index 0000000..777d74d --- /dev/null +++ b/Models/Response/Picture/PictureResponse.cs @@ -0,0 +1,26 @@ +using Foxel.Models.DataBase; + +namespace Foxel.Models.Response.Picture; + +public class PictureResponse +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Path { get; set; } + public string? ThumbnailPath { get; set; } + public string Description { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public List? Tags { get; set; } + public DateTime? TakenAt { get; set; } + public ExifInfo? ExifInfo { get; set; } + public int? UserId { get; set; } + public string? Username { get; set; } + public bool IsFavorited { get; set; } + public int FavoriteCount { get; set; } + public int? AlbumId { get; set; } + public string? AlbumName { get; set; } + public PermissionType Permission { get; set; } = PermissionType.Public; + public ProcessingStatus ProcessingStatus { get; set; } + public string? ProcessingError { get; set; } + public int ProcessingProgress { get; set; } +} diff --git a/Models/Response/Tag/TagResponse.cs b/Models/Response/Tag/TagResponse.cs new file mode 100644 index 0000000..c52ddb2 --- /dev/null +++ b/Models/Response/Tag/TagResponse.cs @@ -0,0 +1,19 @@ +namespace Foxel.Models.Response.Tag; + +public class TagResponse +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int PictureCount { get; set; } = 0; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class TagWithCount +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int Count { get; set; } = 0; +} diff --git a/MyDbContext.cs b/MyDbContext.cs new file mode 100644 index 0000000..14bec42 --- /dev/null +++ b/MyDbContext.cs @@ -0,0 +1,26 @@ +using Foxel.Models.DataBase; +using Microsoft.EntityFrameworkCore; + +namespace Foxel; + +public class MyDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresExtension("vector"); + + modelBuilder.Entity() + .HasIndex(p => p.Embedding) + .HasMethod("ivfflat") + .HasOperators("vector_cosine_ops") + .HasStorageParameter("lists", 100); + } + + public DbSet Pictures { get; set; } = null!; + public DbSet Users { get; set; } = null!; + public DbSet Tags { get; set; } = null!; + public DbSet Configs { get; set; } = null!; + public DbSet Favorites { get; set; } = null!; + public DbSet Albums { get; set; } = null!; + public DbSet Roles { get; set; } = null!; +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..fde6d3a --- /dev/null +++ b/Program.cs @@ -0,0 +1,39 @@ +using Foxel.Extensions; +using Foxel.Services.Interface; + +var builder = WebApplication.CreateBuilder(args); +var environment = builder.Environment; +Console.WriteLine($"当前环境: {environment.EnvironmentName}"); +builder.Services.AddMemoryCache(); +builder.Services.AddApplicationDbContext(builder.Configuration); +builder.Services.AddApplicationOpenApi(); +builder.Services.AddControllers(); +builder.Services.AddHttpClient(); +builder.Services.AddCoreServices(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddApplicationAuthentication(); +builder.Services.AddApplicationAuthorization(); +builder.Services.AddApplicationCors(); + +var app = builder.Build(); + +// 初始化数据库配置 +using (var scope = app.Services.CreateScope()) +{ + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.InitializeAsync(); +} + +app.UseApplicationStaticFiles(); +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} +app.UseHttpsRedirection(); +app.UseApplicationOpenApi(); +app.UseCors("MyAllowSpecificOrigins"); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..f1db193 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7074;http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..265d8e5 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +
+ Foxel Logo +

Foxel - 智能图像检索与管理系统

+

+ 核心功能 + 部署 + 使用 + 贡献 +

+
+ +

+ Foxel 是一个基于 .NET 9 开发的现代化智能图像检索与管理系统,集成先进的 AI 视觉模型向量嵌入技术,提供高效的图像搜索与管理功能。 +

+ +--- + +## ✨ 核心功能 + +| 🤖 智能图像检索 | 🗂️ 图像管理 | 🖼️ 图床功能 | +|:----------------------------:|:---------------------------:|:----------------------------:| +| 基于 AI 的图像内容检索与相似度匹配,快速定位目标图片 | 支持图片分类、标签管理、批量操作,多分辨率与格式化处理 | 图片上传、存储与分享,支持多种链接格式,访问权限灵活控制 | + +| 👥 多用户支持 | 💬 轻社交功能 | 🔗 第三方登录 | +|:---------------:|:--------:|:----------------------------:| +| 用户注册、登录、权限与分组管理 | 点赞、评论、分享 | 支持 GitHub、LinuxDo 等第三方账号快捷登录 | + +--- + +## 🚀 部署指南 + +### 📋 前提条件 + +- 已安装 [Docker](https://www.docker.com/)。 + +### ⚙️ 一键部署 + +1. **克隆仓库** + ```bash + git clone https://github.com/DrizzleTime/Foxel.git + cd Foxel + ``` + +2. **构建并运行容器** + ```bash + docker build -t foxel . + docker run -d -p 80:80 --name foxel foxel + ``` + +3. **访问服务** + + 打开浏览器访问 [http://localhost](http://localhost) 即可使用 Foxel。 + +> 如需自定义数据库等配置,可通过修改 `Dockerfile` 或挂载配置文件实现。 + + +--- + +## 📖 使用方法 + +### 🔄 匿名上传 + +1. 访问网站主页。 +2. 拖放图片到上传区域或点击选择文件。 +3. 上传完成后,系统会生成多种链接格式供分享。 + +### 👤 用户功能 + +- **注册/登录**:创建账户或通过第三方登录。 +- **图片管理**:查看、编辑、删除和搜索图片。 +- **批量操作**:支持批量上传和管理。 + +--- + +## 🤝 贡献指南 + +我们欢迎所有对 Foxel 感兴趣的开发者加入贡献,共同改进和提升这个项目。 + +| 步骤 | 说明 | +|:------------:|:--------------------------------------------------------------------------------------------| +| **提交 Issue** | - 发现 Bug 或有建议时,请提交 Issue。
- 请详细描述问题及复现步骤,便于快速定位和修复。 | +| **贡献代码** | - Fork 本项目并创建新分支。
- 遵循项目代码规范。 | +| **功能扩展** | - 欢迎参与以下重点功能开发:
• 更智能的图像检索算法
• 增强社交互动
• 云存储/网盘集成
• 更多智能图像处理方法(如自动标注、风格迁移、图像增强等) | + +如有任何疑问或建议,欢迎通过 Issue 与我们联系。感谢您的贡献! + +--- +

+ MIT License + 感谢 +

diff --git a/Services/AiService.cs b/Services/AiService.cs new file mode 100644 index 0000000..521ce2d --- /dev/null +++ b/Services/AiService.cs @@ -0,0 +1,434 @@ +using System.Net.Http.Headers; +using System.Text.Json.Serialization; +using Foxel.Services.Interface; +using Foxel.Utils; + +namespace Foxel.Services; + +public class AiService : IAiService +{ + private readonly HttpClient _httpClient; + private readonly IConfigService _configService; + + public AiService(HttpClient httpClient, IConfigService configService) + { + _httpClient = httpClient; + _configService = configService; + string apiKey = _configService["AI:ApiKey"]; + string baseUrl = _configService["AI:ApiEndpoint"]; + _httpClient.BaseAddress = new Uri(baseUrl); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + + public async Task<(string title, string description)> AnalyzeImageAsync(string base64Image) + { + try + { + string model = _configService["AI:Model"]; + var imageUrl = new ImageUrl + { + Url = $"data:image/jpeg;base64,{base64Image}" + }; + + var imageContent = new ImageUrlContent + { + Type = "image_url", + ImageUrl = imageUrl + }; + + var textContent = new TextContent + { + Type = "text", + Text = + "请详细分析这张图片,并提供全面的描述,以便用于向量嵌入和基于文本的图像搜索。描述需要包含:主体对象、场景环境、色彩特点、构图布局、风格特征、情绪氛围、细节特征等关键元素。请提供一个简短有力的标题,然后提供详细描述。\n\n请以JSON格式返回,格式如下:\n{\"title\": \"简短概括图片的核心内容\", \"description\": \"全面详细的描述,包含上述所有元素,使用丰富精确的词汇,避免笼统表达\"}\n\n请确保返回有效的JSON格式。" + }; + + var message = new ChatMessage + { + Role = "user", + Content = new MessageContent[] { imageContent, textContent } + }; + + var requestContent = new ChatCompletionRequest + { + Model = model, + Messages = [message], + Stream = false, + MaxTokens = 800, + Temperature = 0.5, + TopP = 0.8, + TopK = 50 + }; + + var response = await _httpClient.PostAsJsonAsync("/v1/chat/completions", requestContent); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadFromJsonAsync(); + if (responseContent?.Choices == null || responseContent.Choices.Length == 0) + { + return ("未能获取标题", "未能获取描述"); + } + + var aiMessage = responseContent.Choices[0].Message.Content; + return AiHelper.ExtractTitleAndDescription(aiMessage); + } + catch (Exception ex) + { + Console.WriteLine($"AI分析图片时出错: {ex.Message}"); + return ("处理失败", $"AI分析过程中发生错误: {ex.Message}"); + } + } + + public async Task> MatchTagsAsync(string description, List availableTags) + { + try + { + if (availableTags.Count == 0) + return new List(); + + string model = _configService["AI:Model"] ?? "deepseek-ai/deepseek-vl2"; // Assuming model can still be dynamic or default + var tagsText = string.Join(", ", availableTags); + var textContent = new TextContent + { + Type = "text", + Text = + $"以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与下面描述内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n描述内容:{description}\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n\n请确保返回有效的JSON格式前面不要加```,并且只包含确实匹配的标签名称。" + }; + + var message = new ChatMessage + { + Role = "user", + Content = new MessageContent[] { textContent } + }; + + var requestContent = new ChatCompletionRequest + { + Model = model, + Messages = new ChatMessage[] { message }, + Stream = false, + MaxTokens = 200, + Temperature = 0.1, // 降低温度使结果更确定性 + TopP = 0.95, + TopK = 50 + }; + + var response = await _httpClient.PostAsJsonAsync("/v1/chat/completions", requestContent); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadFromJsonAsync(); + if (responseContent?.Choices == null || responseContent.Choices.Length == 0) + { + return new List(); + } + + var aiMessage = responseContent.Choices[0].Message.Content; + + if (string.IsNullOrEmpty(aiMessage)) + return new List(); + + if (aiMessage.Contains("{") && aiMessage.Contains("}")) + { + try + { + int jsonStartIndex = aiMessage.IndexOf('{'); + int jsonEndIndex = aiMessage.LastIndexOf('}') + 1; + + if (jsonStartIndex >= 0 && jsonEndIndex > jsonStartIndex) + { + string jsonPart = aiMessage[jsonStartIndex..jsonEndIndex]; + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var result = + System.Text.Json.JsonSerializer.Deserialize(jsonPart, options); + if (result is { Tags.Length: > 0 }) + { + // 确保返回的标签真的在可用标签列表中 + var availableTagsSet = new HashSet(availableTags, StringComparer.OrdinalIgnoreCase); + var matchedTags = new List(); + + foreach (var tagName in result.Tags) + { + if (string.IsNullOrWhiteSpace(tagName)) + continue; + + // 找到大小写完全匹配的标签 + var exactMatch = availableTags.FirstOrDefault(t => + string.Equals(t, tagName, StringComparison.OrdinalIgnoreCase)); + + if (exactMatch != null) + { + matchedTags.Add(exactMatch); + } + } + + return matchedTags.Distinct().ToList(); + } + } + } + catch (System.Text.Json.JsonException) + { + // JSON解析失败,返回空列表 + return new List(); + } + } + + // 解析失败或没有找到匹配标签,返回空列表 + return new List(); + } + catch (Exception ex) + { + Console.WriteLine($"AI匹配标签时出错: {ex.Message}"); + return new List(); + } + } + + public async Task> GenerateTagsFromImageAsync(string base64Image, List availableTags, + bool allowNewTags = false) + { + try + { + + string model = _configService["AI:Model"] ?? "deepseek-ai/deepseek-vl2"; // Assuming model can still be dynamic or default + + var imageUrl = new ImageUrl + { + Url = $"data:image/jpeg;base64,{base64Image}" + }; + + var imageContent = new ImageUrlContent + { + Type = "image_url", + ImageUrl = imageUrl + }; + + string promptText; + + if (allowNewTags) + { + // 如果允许新标签,则提供现有标签作为参考,但允许生成新标签 + promptText = availableTags.Count > 0 + ? $"可以参考这些现有标签:[{string.Join(", ", availableTags)}],但也可以生成其他与图片内容相关的新标签。\n\n请为图片生成5个最相关的标签,优先使用已有标签,但如果有更恰当的新标签也可以使用。\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]}}\n\n请确保返回有效的JSON格式。" + : "请为图片生成5个最相关的标签,每个标签应该是简短且描述性的词语或短语。\n\n请以JSON格式返回,格式如下:\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]}\n\n请确保返回有效的JSON格式。"; + } + else + { + // 如果不允许新标签,则只能从已有标签中选择 + if (availableTags.Count == 0) + return new List(); + + var tagsText = string.Join(", ", availableTags); + promptText = + $"以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与图片内容高度相关的标签(最多选择5个)。只选择确实匹配的标签,如果找不到完全匹配或高度相关的标签,宁可返回空数组也不要选择不太相关的标签。\n\n请以JSON格式返回,格式如下:\n{{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n\n请确保返回有效的JSON格式,并且只包含上述列表中的标签名称。"; + } + + var textContent = new TextContent + { + Type = "text", + Text = promptText + }; + + var message = new ChatMessage + { + Role = "user", + Content = new MessageContent[] { imageContent, textContent } + }; + + var requestContent = new ChatCompletionRequest + { + Model = model, + Messages = new ChatMessage[] { message }, + Stream = false, + MaxTokens = 200, + Temperature = 0.1, // 降低温度使结果更确定性 + TopP = 0.95, + TopK = 50 + }; + + var response = await _httpClient.PostAsJsonAsync("/v1/chat/completions", requestContent); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadFromJsonAsync(); + if (responseContent?.Choices == null || responseContent.Choices.Length == 0) + { + return new List(); + } + + var aiMessage = responseContent.Choices[0].Message.Content; + + if (string.IsNullOrEmpty(aiMessage)) + return new List(); + + if (aiMessage.Contains("{") && aiMessage.Contains("}")) + { + try + { + int jsonStartIndex = aiMessage.IndexOf('{'); + int jsonEndIndex = aiMessage.LastIndexOf('}') + 1; + + if (jsonStartIndex >= 0 && jsonEndIndex > jsonStartIndex) + { + string jsonPart = aiMessage[jsonStartIndex..jsonEndIndex]; + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var result = + System.Text.Json.JsonSerializer.Deserialize(jsonPart, options); + if (result is { Tags.Length: > 0 }) + { + var matchedTags = new List(); + + foreach (var tagName in result.Tags) + { + if (string.IsNullOrWhiteSpace(tagName)) + continue; + + // 如果允许新标签,直接添加 + if (allowNewTags) + { + matchedTags.Add(tagName.Trim()); + } + else + { + // 否则只添加已有标签列表中的标签 + var exactMatch = availableTags.FirstOrDefault(t => + string.Equals(t, tagName, StringComparison.OrdinalIgnoreCase)); + + if (exactMatch != null) + { + matchedTags.Add(exactMatch); + } + } + } + + return matchedTags.Distinct().ToList(); + } + } + } + catch (System.Text.Json.JsonException) + { + // JSON解析失败,返回空列表 + return new List(); + } + } + + // 解析失败或没有找到匹配标签,返回空列表 + return new List(); + } + catch (Exception ex) + { + Console.WriteLine($"AI从图片生成标签时出错: {ex.Message}"); + return new List(); + } + } + + public async Task GetEmbeddingAsync(string text) + { + try + { + + string model = _configService["AI:EmbeddingModel"]; + + var requestContent = new + { + model = model, + input = text, + encoding_format = "float" + }; + + var response = await _httpClient.PostAsJsonAsync("/v1/embeddings", requestContent); + response.EnsureSuccessStatusCode(); + + var embedResult = await response.Content.ReadFromJsonAsync(); + if (embedResult?.Data == null || embedResult.Data.Length == 0) + { + Console.WriteLine("嵌入向量API返回空结果"); + return Array.Empty(); + } + + return embedResult.Data[0].Embedding; + } + catch (Exception ex) + { + Console.WriteLine($"获取嵌入向量时出错: {ex.Message}"); + return Array.Empty(); + } + } + + // 从EmbeddingService移植的私有记录类 + private record EmbeddingResponse + { + [JsonPropertyName("data")] public EmbeddingData[] Data { get; set; } = Array.Empty(); + } + + private record EmbeddingData + { + [JsonPropertyName("embedding")] public float[] Embedding { get; set; } = Array.Empty(); + } + + private class AiResponse + { + [JsonPropertyName("choices")] public Choice[] Choices { get; set; } = Array.Empty(); + } + + private class Choice + { + [JsonPropertyName("message")] public Message Message { get; set; } = new Message(); + } + + private class Message + { + [JsonPropertyName("content")] public string Content { get; set; } = string.Empty; + } +} + +public class ChatCompletionRequest +{ + [JsonPropertyName("model")] public string Model { get; set; } = string.Empty; + + [JsonPropertyName("messages")] public ChatMessage[] Messages { get; set; } = Array.Empty(); + + [JsonPropertyName("stream")] public bool Stream { get; set; } + + [JsonPropertyName("max_tokens")] public int MaxTokens { get; set; } + + [JsonPropertyName("temperature")] public double Temperature { get; set; } + + [JsonPropertyName("top_p")] public double TopP { get; set; } + + [JsonPropertyName("top_k")] public int TopK { get; set; } +} + +public class ChatMessage +{ + [JsonPropertyName("role")] public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] public MessageContent[] Content { get; set; } = Array.Empty(); +} + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")] +[JsonDerivedType(typeof(ImageUrlContent), typeDiscriminator: "image_url")] +public abstract class MessageContent +{ + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; +} + +public class TextContent : MessageContent +{ + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; +} + +public class ImageUrlContent : MessageContent +{ + [JsonPropertyName("image_url")] public ImageUrl ImageUrl { get; set; } = new(); +} + +public class ImageUrl +{ + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Services/AlbumService.cs b/Services/AlbumService.cs new file mode 100644 index 0000000..5202e22 --- /dev/null +++ b/Services/AlbumService.cs @@ -0,0 +1,302 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Models.Response.Album; +using Foxel.Models.Response.Picture; +using Foxel.Services.Interface; +using Foxel.Utils; + +namespace Foxel.Services; + +public class AlbumService : IAlbumService +{ + private readonly IDbContextFactory _contextFactory; + private readonly IConfigService _configuration; + private readonly IHttpContextAccessor _httpContextAccessor; + + private string ServerUrl => _configuration["AppSettings:ServerUrl"]; + + public AlbumService(IDbContextFactory contextFactory, IConfigService configuration, IHttpContextAccessor httpContextAccessor) + { + _contextFactory = contextFactory; + _configuration = configuration; + _httpContextAccessor = httpContextAccessor; + } + + public async Task> GetAlbumsAsync(int page = 1, int pageSize = 10, int? userId = null) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 10; + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + // 构建查询 + IQueryable query = dbContext.Albums + .Include(a => a.User) + .OrderByDescending(a => a.CreatedAt); + + // 如果指定了用户ID,则只获取该用户的相册 + if (userId.HasValue) + { + query = query.Where(a => a.UserId == userId.Value); + } + + // 获取总数和分页数据 + var totalCount = await query.CountAsync(); + var albums = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + // 获取每个相册中的图片数量 + var albumIds = albums.Select(a => a.Id).ToList(); + var albumPictureCounts = await dbContext.Pictures + .Where(p => p.AlbumId != null && albumIds.Contains(p.AlbumId.Value)) + .GroupBy(p => p.AlbumId) + .Select(g => new { AlbumId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.AlbumId!.Value, x => x.Count); + + // 转换为响应模型 + var albumResponses = albums.Select(a => new AlbumResponse + { + Id = a.Id, + Name = a.Name, + Description = a.Description, + PictureCount = albumPictureCounts.TryGetValue(a.Id, out var count) ? count : 0, + UserId = a.UserId, + Username = a.User?.UserName, + CreatedAt = a.CreatedAt, + UpdatedAt = a.UpdatedAt + }).ToList(); + + return new PaginatedResult + { + Data = albumResponses, + Page = page, + PageSize = pageSize, + TotalCount = totalCount + }; + } + + public async Task GetAlbumByIdAsync(int id) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + var album = await dbContext.Albums + .Include(a => a.User) + .FirstOrDefaultAsync(a => a.Id == id); + + if (album == null) + throw new KeyNotFoundException($"找不到ID为{id}的相册"); + + // 获取相册中图片的数量 + var pictureCount = await dbContext.Pictures + .Where(p => p.AlbumId == id) + .CountAsync(); + + // 转换为响应模型 + var response = new AlbumResponse + { + Id = album.Id, + Name = album.Name, + Description = album.Description, + PictureCount = pictureCount, + UserId = album.UserId, + Username = album.User?.UserName, + CreatedAt = album.CreatedAt, + UpdatedAt = album.UpdatedAt + }; + + return response; + } + + public async Task CreateAlbumAsync(string name, string? description, int userId) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("相册名称不能为空", nameof(name)); + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + // 检查用户是否存在 + var user = await dbContext.Users.FindAsync(userId); + if (user == null) + throw new KeyNotFoundException($"找不到ID为{userId}的用户"); + + // 创建新相册 + var album = new Album + { + Name = name.Trim(), + Description = description?.Trim() ?? string.Empty, + UserId = userId, + User = user, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + dbContext.Albums.Add(album); + await dbContext.SaveChangesAsync(); + + // 转换为响应模型 + return new AlbumResponse + { + Id = album.Id, + Name = album.Name, + Description = album.Description, + PictureCount = 0, + UserId = album.UserId, + Username = user.UserName, + CreatedAt = album.CreatedAt, + UpdatedAt = album.UpdatedAt + }; + } + + public async Task UpdateAlbumAsync(int id, string name, string? description, int? userId = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("相册名称不能为空", nameof(name)); + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + // 获取相册 + var album = await dbContext.Albums + .Include(a => a.User) + .Include(a => a.Pictures) + .FirstOrDefaultAsync(a => a.Id == id); + + if (album == null) + throw new KeyNotFoundException($"找不到ID为{id}的相册"); + + // 权限检查 - 只有相册的创建者或系统管理员可以更新 + if (userId.HasValue && album.UserId != userId.Value) + { + // 检查用户是否是管理员 + var user = await dbContext.Users.FindAsync(userId.Value); + if (user == null) + { + throw new UnauthorizedAccessException("您没有权限更新此相册"); + } + } + + // 更新相册信息 + album.Name = name.Trim(); + album.Description = description?.Trim() ?? album.Description; + album.UpdatedAt = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(); + + // 转换为响应模型 + return new AlbumResponse + { + Id = album.Id, + Name = album.Name, + Description = album.Description, + PictureCount = album.Pictures?.Count ?? 0, + UserId = album.UserId, + Username = album.User?.UserName, + CreatedAt = album.CreatedAt, + UpdatedAt = album.UpdatedAt + }; + } + + public async Task DeleteAlbumAsync(int id) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + var album = await dbContext.Albums.FindAsync(id); + if (album == null) + return false; + + // 注意:相册删除前,需要确保关联的图片被正确处理 + // 这里只移除相册,而不删除图片 + + dbContext.Albums.Remove(album); + await dbContext.SaveChangesAsync(); + + return true; + } + + public async Task AddPictureToAlbumAsync(int albumId, int pictureId) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + // 获取相册和图片 + var album = await dbContext.Albums.FindAsync(albumId); + if (album == null) + throw new KeyNotFoundException($"找不到ID为{albumId}的相册"); + + var picture = await dbContext.Pictures.FindAsync(pictureId); + if (picture == null) + throw new KeyNotFoundException($"找不到ID为{pictureId}的图片"); + + // 将图片添加到相册 + picture.AlbumId = albumId; + picture.Album = album; + + await dbContext.SaveChangesAsync(); + + return true; + } + + public async Task RemovePictureFromAlbumAsync(int albumId, int pictureId) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + // 获取图片 + var picture = await dbContext.Pictures + .FirstOrDefaultAsync(p => p.Id == pictureId && p.AlbumId == albumId); + + if (picture == null) + throw new KeyNotFoundException($"在相册中找不到ID为{pictureId}的图片"); + + // 从相册中移除图片 + picture.AlbumId = null; + picture.Album = null; + + await dbContext.SaveChangesAsync(); + + return true; + } + + public async Task AddPicturesToAlbumAsync(int albumId, List pictureIds) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + var album = await dbContext.Albums.FindAsync(albumId) + ?? throw new KeyNotFoundException("相册不存在"); + + // 检查是否有权限修改此相册 + var currentUser = _httpContextAccessor.HttpContext?.User; + if (currentUser != null) + { + var userId = int.Parse(currentUser.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0"); + if (album.UserId != userId) + { + throw new UnauthorizedAccessException("您没有权限修改此相册"); + } + } + + var successCount = 0; + + foreach (var pictureId in pictureIds) + { + var picture = await dbContext.Pictures.FindAsync(pictureId); + if (picture == null) continue; // 跳过不存在的图片 + + // 直接更新 Picture 的 AlbumId + if (picture.AlbumId != albumId) + { + picture.AlbumId = albumId; + successCount++; + } + } + + if (successCount > 0) + { + await dbContext.SaveChangesAsync(); + return true; + } + + return false; + } +} diff --git a/Services/BackgroundTaskQueue.cs b/Services/BackgroundTaskQueue.cs new file mode 100644 index 0000000..2825f12 --- /dev/null +++ b/Services/BackgroundTaskQueue.cs @@ -0,0 +1,454 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Microsoft.EntityFrameworkCore; +using Foxel.Models.DataBase; +using Foxel.Services.Interface; +using Foxel.Utils; +using Foxel.Services.StorageProvider; + +namespace Foxel.Services; + +public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable +{ + private readonly Channel _queue; + private readonly ConcurrentDictionary _activeTasks; + private readonly ConcurrentDictionary _pictureStatus; + private readonly IServiceProvider _serviceProvider; + private readonly IDbContextFactory _contextFactory; + private readonly List _processingTasks; + private readonly SemaphoreSlim _signal; + private readonly int _maxConcurrentTasks; + private bool _isDisposed; + + public BackgroundTaskQueue( + IServiceProvider serviceProvider, + IDbContextFactory contextFactory, + IConfigService configuration) + { + _serviceProvider = serviceProvider; + _contextFactory = contextFactory; + _activeTasks = new ConcurrentDictionary(); + _pictureStatus = new ConcurrentDictionary(); + _processingTasks = new List(); + _maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 4).Result; + _signal = new SemaphoreSlim(_maxConcurrentTasks); + var options = new BoundedChannelOptions(10000) + { + FullMode = BoundedChannelFullMode.Wait + }; + _queue = Channel.CreateBounded(options); + } + + public async Task QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath) + { + var task = new PictureProcessingTask + { + Id = Guid.NewGuid(), + PictureId = pictureId, + OriginalFilePath = originalFilePath, + CreatedAt = DateTime.UtcNow + }; + + // 更新状态字典 + var status = new PictureProcessingStatus + { + TaskId = task.Id, + PictureId = pictureId, + Status = ProcessingStatus.Pending, + Progress = 0, + CreatedAt = DateTime.UtcNow + }; + + // 将用户ID添加到任务状态中,这样可以按用户过滤任务 + using var scope = _serviceProvider.CreateScope(); + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var picture = await dbContext.Pictures + .Include(p => p.User) + .FirstOrDefaultAsync(p => p.Id == pictureId); + + if (picture != null) + { + status.PictureName = picture.Name; + task.UserId = picture.UserId; + } + + _pictureStatus[pictureId] = status; + _activeTasks[task.Id] = task; + await _queue.Writer.WriteAsync(task); + + // 启动处理器,如果没有正在运行 + StartProcessor(); + + return task.Id; + } + + public async Task> GetUserTasksStatusAsync(int userId) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var userPictureIds = await dbContext.Pictures + .Where(p => p.UserId == userId && + (p.ProcessingStatus == ProcessingStatus.Pending || + p.ProcessingStatus == ProcessingStatus.Processing)) + .Select(p => p.Id) + .ToListAsync(); + + return _pictureStatus.Values + .Where(s => userPictureIds.Contains(s.PictureId)) + .OrderByDescending(s => s.CreatedAt) + .ToList(); + } + + public Task GetPictureProcessingStatusAsync(int pictureId) + { + return Task.FromResult(_pictureStatus.GetValueOrDefault(pictureId)); + } + + public async Task RestoreUnfinishedTasksAsync() + { + try + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + + // 获取所有未完成的图片处理任务 + var unfinishedPictures = await dbContext.Pictures + .Where(p => p.ProcessingStatus == ProcessingStatus.Pending || + p.ProcessingStatus == ProcessingStatus.Processing) + .ToListAsync(); + + if (unfinishedPictures.Any()) + { + Console.WriteLine($"正在恢复 {unfinishedPictures.Count} 个未完成的图片处理任务"); + + foreach (var picture in unfinishedPictures) + { + // 构建原始文件路径 + string relativePath = picture.Path.TrimStart('/').Replace("uploads", "Uploads"); + string originalFilePath = Path.Combine(Directory.GetCurrentDirectory(), relativePath); + if (File.Exists(originalFilePath)) + { + // 重新加入队列 + await QueuePictureProcessingTaskAsync(picture.Id, originalFilePath); + Console.WriteLine($"已恢复图片处理任务: ID={picture.Id}, 路径={originalFilePath}"); + } + else + { + // 如果文件不存在,则标记为失败 + picture.ProcessingStatus = ProcessingStatus.Failed; + picture.ProcessingError = "系统重启后找不到原始图片文件"; + Console.WriteLine($"无法恢复图片处理任务: ID={picture.Id}, 找不到文件: {originalFilePath}"); + } + } + + await dbContext.SaveChangesAsync(); + } + else + { + Console.WriteLine("没有需要恢复的图片处理任务"); + } + } + catch (Exception ex) + { + Console.WriteLine($"恢复未完成的任务时发生错误: {ex.Message}"); + } + } + + private void StartProcessor() + { + // 添加新的处理任务,如果当前任务数量小于最大并发数 + while (_processingTasks.Count(t => !t.IsCompleted) < _maxConcurrentTasks) + { + _processingTasks.Add(Task.Run(ProcessTasksAsync)); + } + + // 清理已完成的任务 + _processingTasks.RemoveAll(t => t.IsCompleted); + } + + private async Task ProcessTasksAsync() + { + while (await _queue.Reader.WaitToReadAsync()) + { + await _signal.WaitAsync(); + + try + { + if (_queue.Reader.TryRead(out var task)) + { + await ProcessPictureAsync(task); + } + } + finally + { + _signal.Release(); + } + } + } + + private async Task ProcessPictureAsync(PictureProcessingTask task) + { + if (!_activeTasks.TryGetValue(task.Id, out _) || !_pictureStatus.TryGetValue(task.PictureId, out var status)) + { + return; + } + + // 更新状态为处理中 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 0); + + string localFilePath = ""; + bool isTempFile = false; + + try + { + using var scope = _serviceProvider.CreateScope(); + var pictureService = scope.ServiceProvider.GetRequiredService(); + var aiService = scope.ServiceProvider.GetRequiredService(); + var storageProviderFactory = scope.ServiceProvider.GetRequiredService(); + + // 1. 获取图片信息 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 10); + var dbContext = await _contextFactory.CreateDbContextAsync(); + var picture = await dbContext.Pictures.FindAsync(task.PictureId); + + if (picture == null) + { + throw new Exception($"找不到ID为{task.PictureId}的图片"); + } + + // 根据存储类型获取文件处理路径 + var storageProvider = storageProviderFactory.GetProvider(picture.StorageType); + + if (picture.StorageType == StorageType.Local) + { + // 本地存储,直接使用文件路径 + localFilePath = Path.Combine(Directory.GetCurrentDirectory(), picture.Path.TrimStart('/')); + } + else if (picture.StorageType == StorageType.Telegram) + { + // Telegram存储,需要先下载文件 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 15); + var telegramProvider = (TelegramStorageProvider)storageProvider; + localFilePath = await telegramProvider.DownloadFileAsync(picture.Path); + isTempFile = true; + } + else + { + throw new Exception($"不支持的存储类型: {picture.StorageType}"); + } + + if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath)) + { + throw new Exception($"找不到图片文件: {localFilePath}"); + } + + // 创建缩略图 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 20); + var thumbnailPath = Path.Combine( + Path.GetDirectoryName(localFilePath)!, + Path.GetFileNameWithoutExtension(Path.GetFileName(localFilePath)) + "_thumb" + + Path.GetExtension(localFilePath)); + + await ImageHelper.CreateThumbnailAsync(localFilePath, thumbnailPath, 500); + + // 更新缩略图路径到数据库 + string relativeThumbnailPath = ""; + if (picture.StorageType == StorageType.Local) + { + // 本地存储缩略图 + relativeThumbnailPath = + $"/uploads/{Path.GetRelativePath("Uploads", Path.GetDirectoryName(thumbnailPath)!)}/{Path.GetFileName(thumbnailPath)}"; + picture.ThumbnailPath = relativeThumbnailPath.Replace('\\', '/'); + } + else if (picture.StorageType == StorageType.Telegram) + { + // 对于Telegram存储,缩略图也上传到Telegram + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 25); + var telegramProvider = (TelegramStorageProvider)storageProvider; + + // 上传缩略图到Telegram + using var thumbnailFileStream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read); + var thumbnailFileName = Path.GetFileName(thumbnailPath); + var thumbnailContentType = "image/jpeg"; + if (Path.GetExtension(thumbnailPath).ToLower() == ".png") + { + thumbnailContentType = "image/png"; + } + + // 上传缩略图到Telegram并获取JSON元数据 + string thumbnailMetadata = await telegramProvider.SaveAsync( + thumbnailFileStream, + thumbnailFileName, + thumbnailContentType); + + // 将元数据存储到ThumbnailPath + picture.ThumbnailPath = thumbnailMetadata; + } + + // 3. 提取EXIF信息 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 30); + var exifInfo = await ImageHelper.ExtractExifInfoAsync(localFilePath); + picture.ExifInfo = exifInfo; + + // 4. 从EXIF中提取拍摄时间并确保是UTC格式 + picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal); + + // 5. 将缩略图转换为Base64并调用AI分析 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 50); + string base64Image = await ImageHelper.ConvertImageToBase64(thumbnailPath); + var (title, description) = await aiService.AnalyzeImageAsync(base64Image); + + // 6. 确定最终标题和描述 + string finalTitle = !string.IsNullOrWhiteSpace(title) && title != "AI生成的标题" + ? title + : Path.GetFileNameWithoutExtension(localFilePath); + + string finalDescription = !string.IsNullOrWhiteSpace(description) && description != "AI生成的描述" + ? description + : picture.Description; + + picture.Name = finalTitle; + picture.Description = finalDescription; + + // 7. 生成嵌入向量 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 60); + var combinedText = $"{finalTitle}. {finalDescription}"; + var embedding = await aiService.GetEmbeddingAsync(combinedText); + picture.Embedding = new Pgvector.Vector(embedding); + + // 8. 获取所有可用标签名称 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 70); + var availableTagNames = await dbContext.Tags.Select(t => t.Name).ToListAsync(); + + // 9. 获取匹配的标签名称 - 从图片生成标签 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 80); + var matchedTagNames = await aiService.GenerateTagsFromImageAsync(base64Image, availableTagNames, true); + + // 10. 处理标签 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 90); + var user = await dbContext.Users + .Include(u => u.Tags) + .FirstOrDefaultAsync(u => u.Id == picture.UserId); + + if (user != null && matchedTagNames.Any()) + { + var tagEntities = new List(); + foreach (var tagName in matchedTagNames) + { + var existingTag = await dbContext.Tags + .Include(t => t.Users) + .FirstOrDefaultAsync(t => t.Name.ToLower() == tagName.ToLower()); + + if (existingTag != null) + { + tagEntities.Add(existingTag); + user.Tags ??= new List(); + if (user.Tags.All(t => t.Id != existingTag.Id)) + { + user.Tags.Add(existingTag); + } + } + else + { + var newTag = new Tag { Name = tagName.Trim(), Description = tagName.Trim() }; + dbContext.Tags.Add(newTag); + await dbContext.SaveChangesAsync(); + user.Tags ??= new List(); + user.Tags.Add(newTag); + tagEntities.Add(newTag); + } + } + + picture.Tags = tagEntities; + } + + // 11. 更新图片处理状态为完成 + picture.ProcessingStatus = ProcessingStatus.Completed; + picture.ProcessingProgress = 100; + await dbContext.SaveChangesAsync(); + + // 更新任务状态 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Completed, 100); + status.CompletedAt = DateTime.UtcNow; + } + catch (Exception ex) + { + // 更新状态为失败 + await UpdatePictureStatus(task.PictureId, ProcessingStatus.Failed, 0, ex.Message); + + // 记录错误日志 + Console.WriteLine($"图片处理失败: 图片ID={task.PictureId}, 错误: {ex.Message}"); + } + finally + { + // 如果是临时文件,处理完后删除 + if (isTempFile && File.Exists(localFilePath)) + { + try + { + File.Delete(localFilePath); + } + catch (Exception ex) + { + Console.WriteLine($"删除临时文件失败: {localFilePath}, 错误: {ex.Message}"); + } + } + + // 清理活动任务 + _activeTasks.TryRemove(task.Id, out _); + + // 继续处理队列中的下一个任务 + StartProcessor(); + } + } + + private async Task UpdatePictureStatus(int pictureId, ProcessingStatus status, int progress, string? error = null) + { + if (_pictureStatus.TryGetValue(pictureId, out var currentStatus)) + { + currentStatus.Status = status; + currentStatus.Progress = progress; + currentStatus.Error = error; + } + + // 更新数据库中的状态 + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var picture = await dbContext.Pictures.FindAsync(pictureId); + if (picture != null) + { + picture.ProcessingStatus = status; + picture.ProcessingProgress = progress; + picture.ProcessingError = error; + await dbContext.SaveChangesAsync(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_isDisposed) return; + + if (disposing) + { + _signal.Dispose(); + Task.WhenAll(_processingTasks).Wait(5000); + } + + _isDisposed = true; + } +} + +/// +/// 图片处理任务 +/// +public class PictureProcessingTask +{ + public Guid Id { get; set; } + public int PictureId { get; set; } + public string OriginalFilePath { get; set; } = string.Empty; + public int? UserId { get; set; } + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs new file mode 100644 index 0000000..78da856 --- /dev/null +++ b/Services/ConfigService.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Foxel.Models.DataBase; +using Foxel.Services.Interface; +using System.Text.Json; + +namespace Foxel.Services; + +public class ConfigService( + IDbContextFactory contextFactory, + IMemoryCache memoryCache, + ILogger logger) + : IConfigService +{ + // 缓存过期时间 + private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(30); + + public string this[string key] => GetValueAsync(key).GetAwaiter().GetResult() ?? string.Empty; + + public async Task GetValueAsync(string key) + { + // 尝试从缓存获取配置值 + if (memoryCache.TryGetValue($"config:{key}", out string? cachedValue)) + { + return cachedValue; + } + + // 如果缓存中没有,从数据库获取 + await using var context = await contextFactory.CreateDbContextAsync(); + var config = await context.Configs.FirstOrDefaultAsync(c => c.Key == key); + + if (config == null) + { + // 尝试从环境变量获取 + string? envVarKey = key.ToUpper().Replace(".", "_").Replace("-", "_"); + string? envVarValue = Environment.GetEnvironmentVariable(envVarKey); + + if (!string.IsNullOrEmpty(envVarValue)) + { + memoryCache.Set($"config:{key}", envVarValue, _cacheExpiration); + return envVarValue; + } + + return null; + } + + // 将配置值添加到缓存 + memoryCache.Set($"config:{key}", config.Value, _cacheExpiration); + + return config.Value; + } + + public async Task GetValueAsync(string key, T? defaultValue = default) + { + var value = await GetValueAsync(key); + + if (string.IsNullOrEmpty(value)) + return defaultValue; + + try + { + return JsonSerializer.Deserialize(value); + } + catch (Exception ex) + { + logger.LogError(ex, "无法将配置值反序列化为所需类型: {Type}", typeof(T).Name); + return defaultValue; + } + } + + public async Task GetConfigAsync(string key) + { + await using var context = await contextFactory.CreateDbContextAsync(); + return await context.Configs.FirstOrDefaultAsync(c => c.Key == key); + } + + public async Task> GetAllConfigsAsync() + { + await using var context = await contextFactory.CreateDbContextAsync(); + return await context.Configs.OrderBy(c => c.Key).ToListAsync(); + } + + public async Task SetConfigAsync(string key, string value, string? description = null) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("配置键不能为空", nameof(key)); + + await using var context = await contextFactory.CreateDbContextAsync(); + + var config = await context.Configs.FirstOrDefaultAsync(c => c.Key == key); + + if (config == null) + { + // 创建新配置 + config = new Config + { + Key = key, + Value = value, + Description = description ?? string.Empty, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + context.Configs.Add(config); + } + else + { + // 更新现有配置 + config.Value = value; + if (description != null) + { + config.Description = description; + } + config.UpdatedAt = DateTime.UtcNow; + } + await context.SaveChangesAsync(); + memoryCache.Set($"config:{key}", value, _cacheExpiration); + return config; + } + + public async Task DeleteConfigAsync(string key) + { + await using var context = await contextFactory.CreateDbContextAsync(); + + var config = await context.Configs.FirstOrDefaultAsync(c => c.Key == key); + if (config == null) + return false; + + context.Configs.Remove(config); + await context.SaveChangesAsync(); + memoryCache.Remove($"config:{key}"); + return true; + } + + public async Task ExistsAsync(string key) + { + await using var context = await contextFactory.CreateDbContextAsync(); + return await context.Configs.AnyAsync(c => c.Key == key); + } +} diff --git a/Services/DatabaseInitializer.cs b/Services/DatabaseInitializer.cs new file mode 100644 index 0000000..0dfd737 --- /dev/null +++ b/Services/DatabaseInitializer.cs @@ -0,0 +1,42 @@ +using Foxel.Models.DataBase; +using Foxel.Services.Interface; +using Microsoft.EntityFrameworkCore; + +namespace Foxel.Services; + +public class DatabaseInitializer( + IDbContextFactory contextFactory, + IConfigService configService, + ILogger logger) + : IDatabaseInitializer +{ + public async Task InitializeAsync() + { + logger.LogInformation("开始初始化数据库配置..."); + + using var context = await contextFactory.CreateDbContextAsync(); + + // 确保数据库已创建 + await context.Database.EnsureCreatedAsync(); + + // 初始化JWT配置 - + await EnsureConfigExistsAsync("Jwt:SecretKey", "ChAtPiCdEfAuLtSeCrEtKeY2023_Extended_Secure_Key"); + await EnsureConfigExistsAsync("Jwt:Issuer", "Foxel"); + await EnsureConfigExistsAsync("Jwt:Audience", "FoxelUsers"); + + // 初始化GitHub认证配置 + await EnsureConfigExistsAsync("Authentication:GitHubClientId", "placeholder_replace_with_actual_github_client_id"); + await EnsureConfigExistsAsync("Authentication:GitHubClientSecret", "placeholder_replace_with_actual_github_client_secret"); + + logger.LogInformation("数据库配置初始化完成"); + } + + private async Task EnsureConfigExistsAsync(string key, string value) + { + if (!await configService.ExistsAsync(key)) + { + logger.LogInformation("创建配置项: {Key}", key); + await configService.SetConfigAsync(key, value, $"自动创建的{key}配置"); + } + } +} diff --git a/Services/Interface/IAiService.cs b/Services/Interface/IAiService.cs new file mode 100644 index 0000000..50d715b --- /dev/null +++ b/Services/Interface/IAiService.cs @@ -0,0 +1,35 @@ +namespace Foxel.Services.Interface; + +public interface IAiService +{ + /// + /// 分析图像并返回标题和描述 + /// + /// Base64格式的图像数据 + /// 图像的标题和描述 + Task<(string title, string description)> AnalyzeImageAsync(string base64Image); + + /// + /// 基于描述匹配标签 + /// + /// 图片描述 + /// 可用标签列表 + /// 匹配的标签名称列表 + Task> MatchTagsAsync(string description, List availableTags); + + /// + /// 直接从图像生成标签 + /// + /// Base64格式的图像数据 + /// 可用标签列表 + /// 是否允许生成新标签(不在availableTags中的标签) + /// 匹配的标签名称列表 + Task> GenerateTagsFromImageAsync(string base64Image, List availableTags, bool allowNewTags = false); + + /// + /// 获取文本的嵌入向量 + /// + /// 需要进行嵌入的文本 + /// 表示文本语义的浮点数组向量 + Task GetEmbeddingAsync(string text); +} diff --git a/Services/Interface/IAlbumService.cs b/Services/Interface/IAlbumService.cs new file mode 100644 index 0000000..4441c15 --- /dev/null +++ b/Services/Interface/IAlbumService.cs @@ -0,0 +1,17 @@ +using Foxel.Models; +using Foxel.Models.Response; +using Foxel.Models.Response.Album; + +namespace Foxel.Services.Interface; + +public interface IAlbumService +{ + Task> GetAlbumsAsync(int page = 1, int pageSize = 10, int? userId = null); + Task GetAlbumByIdAsync(int id); + Task CreateAlbumAsync(string name, string? description, int userId); + Task UpdateAlbumAsync(int id, string name, string? description, int? userId = null); + Task DeleteAlbumAsync(int id); + Task AddPictureToAlbumAsync(int albumId, int pictureId); + Task AddPicturesToAlbumAsync(int albumId, List pictureIds); + Task RemovePictureFromAlbumAsync(int albumId, int pictureId); +} diff --git a/Services/Interface/IBackgroundTaskQueue.cs b/Services/Interface/IBackgroundTaskQueue.cs new file mode 100644 index 0000000..555a7c8 --- /dev/null +++ b/Services/Interface/IBackgroundTaskQueue.cs @@ -0,0 +1,51 @@ +using Foxel.Models.DataBase; + +namespace Foxel.Services.Interface; + +/// +/// 后台任务队列接口 +/// +public interface IBackgroundTaskQueue +{ + /// + /// 将图片处理任务添加到队列 + /// + /// 图片ID + /// 原始图片路径 + /// 任务ID + Task QueuePictureProcessingTaskAsync(int pictureId, string originalFilePath); + + /// + /// 获取用户的所有任务状态 + /// + /// 用户ID + /// 该用户的任务状态列表 + Task> GetUserTasksStatusAsync(int userId); + + /// + /// 获取特定图片的处理状态 + /// + /// 图片ID + /// 处理状态 + Task GetPictureProcessingStatusAsync(int pictureId); + + /// + /// 恢复未完成的任务 + /// + Task RestoreUnfinishedTasksAsync(); +} + +/// +/// 图片处理状态 +/// +public class PictureProcessingStatus +{ + public int PictureId { get; set; } + public Guid TaskId { get; set; } + public string PictureName { get; set; } = string.Empty; + public ProcessingStatus Status { get; set; } + public int Progress { get; set; } // 0-100 + public string? Error { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } +} diff --git a/Services/Interface/IConfigService.cs b/Services/Interface/IConfigService.cs new file mode 100644 index 0000000..9984aee --- /dev/null +++ b/Services/Interface/IConfigService.cs @@ -0,0 +1,19 @@ +using Foxel.Models.DataBase; + +namespace Foxel.Services.Interface; + +public interface IConfigService +{ + string this[string key] { get; } + + Task GetValueAsync(string key); + Task GetValueAsync(string key, T? defaultValue = default); + Task GetConfigAsync(string key); + Task> GetAllConfigsAsync(); + + Task SetConfigAsync(string key, string value, string? description = null); + + Task DeleteConfigAsync(string key); + + Task ExistsAsync(string key); +} diff --git a/Services/Interface/IDatabaseInitializer.cs b/Services/Interface/IDatabaseInitializer.cs new file mode 100644 index 0000000..4c540e5 --- /dev/null +++ b/Services/Interface/IDatabaseInitializer.cs @@ -0,0 +1,6 @@ +namespace Foxel.Services.Interface; + +public interface IDatabaseInitializer +{ + Task InitializeAsync(); +} diff --git a/Services/Interface/IPictureService.cs b/Services/Interface/IPictureService.cs new file mode 100644 index 0000000..16d5ed9 --- /dev/null +++ b/Services/Interface/IPictureService.cs @@ -0,0 +1,87 @@ +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Models.Response.Picture; + +namespace Foxel.Services.Interface; + +public interface IPictureService +{ + Task> GetPicturesAsync( + int page = 1, + int pageSize = 8, + string? searchQuery = null, + List? tags = null, + DateTime? startDate = null, + DateTime? endDate = null, + int? userId = null, + string? sortBy = "newest", + bool? onlyWithGps = false, + bool useVectorSearch = false, + double similarityThreshold = 0.36, + int? excludeAlbumId = null, + int? albumId = null, + bool onlyFavorites = false, + int? ownerId = null, + bool includeAllPublic = false + ); + + Task<(PictureResponse Picture, int Id)> UploadPictureAsync( + string fileName, + Stream fileStream, + string contentType, + int? userId, + PermissionType permission = PermissionType.Public, + int? albumId = null, + StorageType storageType = StorageType.Telegram + ); + + Task GetPictureExifInfoAsync(int pictureId); + + /// + /// 批量删除多张图片 + /// + /// 要删除的图片ID列表 + /// 每个图片ID对应的删除结果、可能的错误信息和所有者ID + Task> DeleteMultiplePicturesAsync( + List pictureIds); + + /// + /// 更新图片信息 + /// + /// 图片ID + /// 新标题(可选) + /// 新描述(可选) + /// 新标签(可选) + /// 更新后的图片视图模型和所有者ID + Task<(PictureResponse Picture, int? UserId)> UpdatePictureAsync( + int pictureId, + string? name = null, + string? description = null, + List? tags = null); + + /// + /// 收藏图片 + /// + /// 图片ID + /// 用户ID + /// 成功/失败 + Task FavoritePictureAsync(int pictureId, int userId); + + /// + /// 取消收藏图片 + /// + /// 图片ID + /// 用户ID + /// 成功/失败 + Task UnfavoritePictureAsync(int pictureId, int userId); + + /// + /// 检查图片是否被特定用户收藏 + /// + /// 图片ID + /// 用户ID + /// 是否收藏 + Task IsPictureFavoritedByUserAsync(int pictureId, int userId); + + +} \ No newline at end of file diff --git a/Services/Interface/IStorageProvider.cs b/Services/Interface/IStorageProvider.cs new file mode 100644 index 0000000..d44fa9f --- /dev/null +++ b/Services/Interface/IStorageProvider.cs @@ -0,0 +1,30 @@ +namespace Foxel.Services.Interface; + +public interface IStorageProvider +{ + /// + /// 保存文件 + /// + Task SaveAsync(Stream fileStream, string fileName, string contentType); + + /// + /// 删除文件 + /// + Task DeleteAsync(string storagePath); + + /// + /// 获取文件URL + /// + string GetUrl(string storagePath); + + /// + /// 下载文件到本地临时目录 + /// + /// 存储路径 + /// 本地文件路径 + Task DownloadFileAsync(string storagePath) + { + // 默认实现 - 子类应重写此方法 + throw new NotImplementedException("此存储提供者不支持下载文件功能"); + } +} \ No newline at end of file diff --git a/Services/Interface/IStorageProviderFactory.cs b/Services/Interface/IStorageProviderFactory.cs new file mode 100644 index 0000000..4fb18dc --- /dev/null +++ b/Services/Interface/IStorageProviderFactory.cs @@ -0,0 +1,14 @@ +using Foxel.Models.DataBase; +using Foxel.Services.Interface; + +namespace Foxel.Services.Interface; + +public interface IStorageProviderFactory +{ + /// + /// 根据存储类型获取对应的存储提供者 + /// + /// 存储类型 + /// 存储提供者实例 + IStorageProvider GetProvider(StorageType storageType); +} diff --git a/Services/Interface/ITagService.cs b/Services/Interface/ITagService.cs new file mode 100644 index 0000000..b8c9cf7 --- /dev/null +++ b/Services/Interface/ITagService.cs @@ -0,0 +1,24 @@ +using Foxel.Models; +using Foxel.Models.Response.Tag; + +namespace Foxel.Services.Interface; + +public interface ITagService +{ + Task> GetFilteredTagsAsync( + int page = 1, + int pageSize = 20, + string? searchQuery = null, + string? sortBy = "pictureCount", + string? sortDirection = "desc", + int? minPictureCount = null + ); + + Task GetTagByIdAsync(int id); + + Task CreateTagAsync(string name, string? description = null); + + Task UpdateTagAsync(int id, string? name = null, string? description = null); + + Task DeleteTagAsync(int id); +} diff --git a/Services/Interface/IUserService.cs b/Services/Interface/IUserService.cs new file mode 100644 index 0000000..e215c7e --- /dev/null +++ b/Services/Interface/IUserService.cs @@ -0,0 +1,15 @@ +using Foxel.Models.DataBase; +using Foxel.Models.Request; +using Foxel.Models.Request.Auth; + +namespace Foxel.Services.Interface; + +public interface IUserService +{ + Task<(bool success, string message, User? user)> RegisterUserAsync(RegisterRequest request); + Task<(bool success, string message, User? user)> AuthenticateUserAsync(LoginRequest request); + Task GenerateJwtTokenAsync(User user); + Task GetUserByIdAsync(int userId); + Task<(bool success, string message, User? user)> FindOrCreateGitHubUserAsync( + string githubId, string githubLogin, string? email); +} diff --git a/Services/PictureService.cs b/Services/PictureService.cs new file mode 100644 index 0000000..c9fb5fb --- /dev/null +++ b/Services/PictureService.cs @@ -0,0 +1,760 @@ +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Services.Interface; +using Foxel.Utils; +using Microsoft.EntityFrameworkCore; +using Pgvector; +using Pgvector.EntityFrameworkCore; +using System.Text.Json; +using Foxel.Models.Response.Picture; + +namespace Foxel.Services; + +public class PictureService( + IDbContextFactory contextFactory, + IAiService embeddingService, + IConfigService configuration, + IBackgroundTaskQueue backgroundTaskQueue, + IStorageProviderFactory storageProviderFactory) + : IPictureService +{ + private readonly string _serverUrl = configuration["AppSettings:ServerUrl"]; + + public async Task> GetPicturesAsync( + int page = 1, + int pageSize = 8, + string? searchQuery = null, + List? tags = null, + DateTime? startDate = null, + DateTime? endDate = null, + int? userId = null, + string? sortBy = "newest", + bool? onlyWithGps = false, + bool useVectorSearch = false, + double similarityThreshold = 0.36, + int? excludeAlbumId = null, + int? albumId = null, + bool onlyFavorites = false, + int? ownerId = null, + bool includeAllPublic = false + ) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 8; + + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + // 决定是使用向量搜索还是普通搜索 + if (useVectorSearch && !string.IsNullOrWhiteSpace(searchQuery)) + { + return await PerformVectorSearchAsync( + dbContext, page, pageSize, searchQuery, tags, + startDate, endDate, userId, onlyWithGps, similarityThreshold, + excludeAlbumId, albumId, onlyFavorites, ownerId, includeAllPublic); + } + else + { + return await PerformStandardSearchAsync( + dbContext, page, pageSize, searchQuery, tags, + startDate, endDate, userId, sortBy, onlyWithGps, + excludeAlbumId, albumId, onlyFavorites, ownerId, includeAllPublic); + } + } + + // 执行向量搜索 + private async Task> PerformVectorSearchAsync( + MyDbContext dbContext, + int page, + int pageSize, + string searchQuery, + List? tags, + DateTime? startDate, + DateTime? endDate, + int? userId, + bool? onlyWithGps, + double similarityThreshold, + int? excludeAlbumId, + int? albumId, + bool onlyFavorites, + int? ownerId, + bool includeAllPublic) + { + var queryEmbedding = await embeddingService.GetEmbeddingAsync(searchQuery); + var queryVector = new Vector(queryEmbedding); + + // 构建基础查询 + var query = dbContext.Pictures + .Include(p => p.Tags) + .Include(p => p.User) + .Where(p => p.Embedding != null); + + // 应用共通的查询条件 + query = ApplyCommonFilters(query, tags, startDate, endDate, userId, onlyWithGps, + excludeAlbumId, albumId, onlyFavorites, ownerId, includeAllPublic); + + // 执行向量搜索 + var allResults = await query + .Select(p => new + { + Picture = p, + Similarity = 1.0 - p.Embedding!.CosineDistance(queryVector) + }) + .Where(p => p.Similarity >= similarityThreshold) + .OrderByDescending(p => p.Similarity) + .ToListAsync(); + + // 计算总数并分页 + var totalCount = allResults.Count; + + var paginatedResults = allResults + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(r => MapPictureToResponse(r.Picture, _serverUrl)) + .ToList(); + + // 处理收藏信息 + await PopulateFavoriteInfo(dbContext, paginatedResults, userId); + + // 为当前用户的图片添加相册信息 + if (userId.HasValue) + { + await PopulateAlbumInfo(dbContext, paginatedResults, userId.Value); + } + + return new PaginatedResult + { + Data = paginatedResults, + Page = page, + PageSize = pageSize, + TotalCount = totalCount + }; + } + + // 执行标准搜索 + private async Task> PerformStandardSearchAsync( + MyDbContext dbContext, + int page, + int pageSize, + string? searchQuery, + List? tags, + DateTime? startDate, + DateTime? endDate, + int? userId, + string? sortBy, + bool? onlyWithGps, + int? excludeAlbumId, + int? albumId, + bool onlyFavorites, + int? ownerId, + bool includeAllPublic) + { + // 构建基础查询 + IQueryable query = dbContext.Pictures + .Include(p => p.Tags) + .Include(p => p.User); + + // 应用文本搜索条件 + if (!string.IsNullOrWhiteSpace(searchQuery)) + { + var searchTerm = searchQuery.ToLower(); + query = query.Where(p => + (p.Name.ToLower().Contains(searchTerm)) || + (p.Description.ToLower().Contains(searchTerm))); + } + + // 应用共通的查询条件 + query = ApplyCommonFilters(query, tags, startDate, endDate, userId, onlyWithGps, + excludeAlbumId, albumId, onlyFavorites, ownerId, includeAllPublic); + + // 应用排序 + query = ApplySorting(query, sortBy); + + // 获取总记录数 + var totalCount = await query.CountAsync(); + + // 获取分页数据 + var picturesData = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + // 转换为响应格式 + var pictures = picturesData + .Select(p => MapPictureToResponse(p, _serverUrl)) + .ToList(); + + // 处理收藏信息 + await PopulateFavoriteInfo(dbContext, pictures, userId); + + // 为当前用户的图片添加相册信息 + if (userId.HasValue) + { + await PopulateAlbumInfo(dbContext, pictures, userId.Value); + } + + return new PaginatedResult + { + Data = pictures, + Page = page, + PageSize = pageSize, + TotalCount = totalCount + }; + } + + // 应用共通的过滤条件 + private IQueryable ApplyCommonFilters( + IQueryable query, + List? tags, + DateTime? startDate, + DateTime? endDate, + int? userId, + bool? onlyWithGps, + int? excludeAlbumId, + int? albumId, + bool onlyFavorites, + int? ownerId, + bool includeAllPublic) + { + // 应用标签筛选 + if (tags != null && tags.Any()) + { + foreach (var tag in tags) + { + var tagName = tag.Trim(); + if (!string.IsNullOrEmpty(tagName)) + { + var normalizedTagName = tagName.ToLower(); + query = query.Where(p => p.Tags!.Any(t => t.Name.ToLower().Equals(normalizedTagName))); + } + } + } + + // 应用日期范围筛选 + if (startDate.HasValue) + { + DateTime utcStartDate = startDate.Value.ToUniversalTime(); + query = query.Where(p => + (p.TakenAt.HasValue && p.TakenAt >= utcStartDate) || + (!p.TakenAt.HasValue && p.CreatedAt >= utcStartDate)); + } + + if (endDate.HasValue) + { + DateTime utcEndDate = endDate.Value.ToUniversalTime().AddDays(1).AddMilliseconds(-1); + query = query.Where(p => + (p.TakenAt.HasValue && p.TakenAt <= utcEndDate) || + (!p.TakenAt.HasValue && p.CreatedAt <= utcEndDate)); + } + + // 应用用户筛选和权限过滤逻辑 + if (ownerId.HasValue) + { + if (userId.HasValue && userId.Value == ownerId.Value) + { + query = query.Where(p => p.User != null && p.User.Id == ownerId.Value); + } + else + { + query = query.Where(p => + p.User != null && p.User.Id == ownerId.Value && p.Permission == PermissionType.Public); + } + } + else if (userId.HasValue) + { + if (includeAllPublic) + { + query = query.Where(p => + (p.User != null && p.User.Id == userId.Value) || + (p.User != null && p.User.Id != userId.Value && + p.Permission == PermissionType.Public) + ); + } + else + { + query = query.Where(p => p.User != null && p.User.Id == userId.Value); + } + } + else + { + query = query.Where(p => p.Permission == PermissionType.Public); + } + + // 筛选有GPS信息的图片 + if (onlyWithGps == true) + { + query = query.Where(p => + p.ExifInfo != null && + !string.IsNullOrEmpty(p.ExifInfo.GpsLatitude) && + !string.IsNullOrEmpty(p.ExifInfo.GpsLongitude)); + } + + // 排除指定相册的图片 + if (excludeAlbumId.HasValue) + { + query = query.Where(p => p.AlbumId != excludeAlbumId.Value || p.AlbumId == null); + } + + // 筛选指定相册的图片 + if (albumId.HasValue) + { + query = query.Where(p => p.AlbumId == albumId.Value); + } + + // 筛选收藏的图片 + if (onlyFavorites && userId.HasValue) + { + query = query.Where(p => p.Favorites!.Any(f => f.User.Id == userId.Value)); + } + + return query; + } + + // 应用排序 + private IQueryable ApplySorting(IQueryable query, string? sortBy) + { + return sortBy?.ToLower() switch + { + // 拍摄时间排序 + "takenat_desc" or "newest" => query.OrderByDescending(p => p.TakenAt ?? p.CreatedAt), + "takenat_asc" or "oldest" => query.OrderBy(p => p.TakenAt ?? p.CreatedAt), + + // 上传时间排序 + "uploaddate_desc" => query.OrderByDescending(p => p.CreatedAt), + "uploaddate_asc" => query.OrderBy(p => p.CreatedAt), + + // 名称排序 + "name_asc" or "name" => query.OrderBy(p => p.Name), + "name_desc" => query.OrderByDescending(p => p.Name), + + // 默认排序 + _ => query.OrderByDescending(p => p.TakenAt ?? p.CreatedAt) + }; + } + + // 将数据库实体映射到响应对象 + private PictureResponse MapPictureToResponse(Picture picture, string serverUrl) + { + var storageProvider = storageProviderFactory.GetProvider(picture.StorageType); + + return new PictureResponse + { + Id = picture.Id, + Name = picture.Name, + Path = storageProvider.GetUrl(picture.Path), + ThumbnailPath = storageProvider.GetUrl(picture.ThumbnailPath), + Description = picture.Description, + CreatedAt = picture.CreatedAt, + Tags = picture.Tags != null ? picture.Tags.Select(t => t.Name).ToList() : new List(), + TakenAt = picture.TakenAt, + ExifInfo = picture.ExifInfo ?? new ExifInfo(), + UserId = picture.UserId, + Username = picture.User?.UserName, + AlbumId = picture.AlbumId, + Permission = picture.Permission + }; + } + + // 填充收藏信息 + private async Task PopulateFavoriteInfo(MyDbContext dbContext, List pictures, int? userId) + { + if (userId.HasValue && pictures.Any()) + { + var pictureIds = pictures.Select(p => p.Id).ToList(); + + // 获取用户收藏的图片ID + var favoritedPictureIds = await dbContext.Favorites + .Where(f => f.User.Id == userId.Value && pictureIds.Contains(f.PictureId)) + .Select(f => f.PictureId) + .ToHashSetAsync(); // 使用 ToHashSetAsync 提高查找效率 + + // 一次性获取所有相关图片的收藏总数 + var favoriteCounts = await dbContext.Favorites + .Where(f => pictureIds.Contains(f.PictureId)) + .GroupBy(f => f.PictureId) + .Select(g => new { PictureId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.PictureId, x => x.Count); + + foreach (var picture in pictures) + { + picture.IsFavorited = favoritedPictureIds.Contains(picture.Id); + picture.FavoriteCount = favoriteCounts.GetValueOrDefault(picture.Id, 0); + } + } + else if (pictures.Any()) // 如果用户未登录,仍然需要获取收藏总数 + { + var pictureIds = pictures.Select(p => p.Id).ToList(); + var favoriteCounts = await dbContext.Favorites + .Where(f => pictureIds.Contains(f.PictureId)) + .GroupBy(f => f.PictureId) + .Select(g => new { PictureId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.PictureId, x => x.Count); + + foreach (var picture in pictures) + { + picture.IsFavorited = false; // 用户未登录,不可能收藏 + picture.FavoriteCount = favoriteCounts.GetValueOrDefault(picture.Id, 0); + } + } + } + + // 填充相册信息 + private async Task PopulateAlbumInfo(MyDbContext dbContext, List pictures, int userId) + { + if (!pictures.Any()) + return; + + // 获取当前用户拥有的图片ID列表 + var userPictureIds = pictures + .Where(p => p.UserId == userId) + .Select(p => p.Id) + .ToList(); + + if (!userPictureIds.Any()) + return; + + // 获取相册信息 + var pictureAlbums = await dbContext.Pictures + .Where(p => userPictureIds.Contains(p.Id) && p.AlbumId.HasValue) + .Select(p => new { p.Id, p.AlbumId, AlbumName = p.Album!.Name }) + .ToDictionaryAsync(p => p.Id, p => new { p.AlbumId, p.AlbumName }); + + // 填充相册信息到图片响应中 + foreach (var picture in pictures) + { + if (picture.UserId == userId && pictureAlbums.TryGetValue(picture.Id, out var albumInfo)) + { + picture.AlbumId = albumInfo.AlbumId; + picture.AlbumName = albumInfo.AlbumName; + } + } + } + + public async Task<(PictureResponse Picture, int Id)> UploadPictureAsync( + string fileName, + Stream fileStream, + string contentType, + int? userId, + PermissionType permission = PermissionType.Public, + int? albumId = null, + StorageType storageType = StorageType.Local) + { + string fileExtension = Path.GetExtension(fileName); + string newFileName = $"{Guid.NewGuid()}{fileExtension}"; + + // 获取对应的存储提供者 + var storageProvider = storageProviderFactory.GetProvider(storageType); + + // 使用存储提供者保存文件 + string relativePath = await storageProvider.SaveAsync(fileStream, fileName, contentType); + + // 创建基本的Picture对象,使用文件名作为标题和描述 + string initialTitle = Path.GetFileNameWithoutExtension(fileName); + string initialDescription = $"Uploaded on {DateTime.UtcNow}"; + + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + // 获取用户 + User? user = null; + if (userId is not null) + { + user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) + { + throw new Exception("找不到指定的用户"); + } + } + + // 检查相册是否存在并且属于当前用户 + Album? album = null; + if (albumId.HasValue) + { + album = await dbContext.Albums.Include(a => a.User) + .FirstOrDefaultAsync(a => a.Id == albumId.Value); + + if (album == null) + { + throw new KeyNotFoundException($"找不到ID为{albumId.Value}的相册"); + } + + if (album.User.Id != userId) + { + throw new Exception("您无权将图片添加到此相册"); + } + } + + bool isAnonymous = userId == null; + + // 创建图片对象并保存到数据库 + var picture = new Picture + { + Name = initialTitle, + Description = initialDescription, + Path = relativePath, + User = user, + Permission = permission, + AlbumId = albumId, + StorageType = storageType, + ProcessingStatus = isAnonymous ? ProcessingStatus.Completed : ProcessingStatus.Pending, + ThumbnailPath = isAnonymous ? relativePath : null + }; + + dbContext.Pictures.Add(picture); + await dbContext.SaveChangesAsync(); + + if (!isAnonymous) + { + await backgroundTaskQueue.QueuePictureProcessingTaskAsync(picture.Id, relativePath); + } + + // 返回图片基本信息 + var pictureResponse = new PictureResponse + { + Id = picture.Id, + Name = picture.Name, + Path = storageProvider.GetUrl(relativePath), + ThumbnailPath = isAnonymous ? storageProvider.GetUrl(relativePath) : null, + Description = picture.Description, + CreatedAt = picture.CreatedAt, + Tags = new List(), + Permission = permission, + AlbumId = albumId, + AlbumName = album?.Name, + ProcessingStatus = picture.ProcessingStatus + }; + + return (pictureResponse, picture.Id); + } + + public async Task GetPictureExifInfoAsync(int pictureId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + var picture = await dbContext.Pictures.FindAsync(pictureId); + + if (picture == null) + throw new KeyNotFoundException($"找不到ID为{pictureId}的图片"); + + // 如果已有保存的EXIF信息,则直接返回 + if (!string.IsNullOrEmpty(picture.ExifInfoJson)) + { + var exifInfo = JsonSerializer.Deserialize(picture.ExifInfoJson); + return exifInfo ?? new ExifInfo { ErrorMessage = "无法解析EXIF信息" }; + } + + // 否则从文件中提取 + string fullPath = Path.Combine(Directory.GetCurrentDirectory(), picture.Path.TrimStart('/')); + if (!File.Exists(fullPath)) + { + return new ExifInfo { ErrorMessage = "找不到图片文件" }; + } + + return await ImageHelper.ExtractExifInfoAsync(fullPath); + } + + public async Task> DeleteMultiplePicturesAsync( + List pictureIds) + { + var results = new Dictionary(); + if (pictureIds.Count == 0) + return results; + + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var picturesToDelete = await dbContext.Pictures + .Include(p => p.User) + .Where(p => pictureIds.Contains(p.Id)) + .ToListAsync(); + + var foundPictureIds = picturesToDelete.Select(p => p.Id).ToHashSet(); + foreach (var id in pictureIds.Where(id => !foundPictureIds.Contains(id))) + { + results[id] = (false, "找不到此图片", null); + } + + var filesToDelete = + new List<(int PictureId, string Path, string ThumbnailPath, int? UserId, StorageType StorageType)>(); + foreach (var picture in picturesToDelete) + { + filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath, picture.User?.Id, picture.StorageType)); + } + + if (picturesToDelete.Any()) + { + dbContext.Pictures.RemoveRange(picturesToDelete); + await dbContext.SaveChangesAsync(); + } + + foreach (var (pictureId, path, thumbnailPath, userId, storageType) in filesToDelete) + { + try + { + string? errorMsg = null; + + try + { + // 根据存储类型获取相应的存储提供者并删除文件 + var storageProvider = storageProviderFactory.GetProvider(storageType); + await storageProvider.DeleteAsync(path); + + // 删除缩略图 + if (storageType == StorageType.Local) + { + // 对于本地存储,使用本地存储提供者删除缩略图 + await storageProvider.DeleteAsync(thumbnailPath); + } + else + { + // 对于其他存储类型(如Telegram),使用相同的存储提供者删除缩略图 + // 因为缩略图元数据格式与原文件相同 + await storageProvider.DeleteAsync(thumbnailPath); + } + } + catch (Exception ex) + { + errorMsg = $"数据库记录已删除,但删除文件失败: {ex.Message}"; + Console.WriteLine($"删除图片文件时出错:{ex.Message}"); + } + + results[pictureId] = (true, errorMsg, userId); + } + catch (Exception ex) + { + results[pictureId] = (false, $"处理图片删除时出错: {ex.Message}", userId); + } + } + + return results; + } + + public async Task<(PictureResponse Picture, int? UserId)> UpdatePictureAsync( + int pictureId, + string? name = null, + string? description = null, + List? tags = null) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var picture = await dbContext.Pictures + .Include(p => p.User) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.Id == pictureId); + + if (picture == null) + throw new KeyNotFoundException($"找不到ID为{pictureId}的图片"); + + var userId = picture.User?.Id; + + if (!string.IsNullOrWhiteSpace(name)) + { + picture.Name = name.Trim(); + } + + if (!string.IsNullOrWhiteSpace(description)) + { + picture.Description = description.Trim(); + } + + if (!string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(description)) + { + var combinedText = $"{picture.Name}. {picture.Description}"; + var embedding = await embeddingService.GetEmbeddingAsync(combinedText); + picture.Embedding = new Vector(embedding); + } + + if (tags != null) + { + picture.Tags?.Clear(); + + foreach (var tagName in tags.Where(t => !string.IsNullOrWhiteSpace(t))) + { + var tag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Name.ToLower() == tagName.ToLower().Trim()); + + if (tag == null) + { + tag = new Tag { Name = tagName.Trim() }; + dbContext.Tags.Add(tag); + } + + picture.Tags?.Add(tag); + } + } + + picture.UpdatedAt = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(); + + var pictureResponse = new PictureResponse + { + Id = picture.Id, + Name = picture.Name, + Path = storageProviderFactory.GetProvider(picture.StorageType).GetUrl(picture.Path), + ThumbnailPath = storageProviderFactory.GetProvider(picture.StorageType).GetUrl(picture.ThumbnailPath), + Description = picture.Description, + CreatedAt = picture.CreatedAt, + Tags = picture.Tags?.Select(t => t.Name).ToList() ?? new List(), + TakenAt = picture.TakenAt, + ExifInfo = picture.ExifInfo + }; + + return (pictureResponse, userId); + } + + public async Task FavoritePictureAsync(int pictureId, int userId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + // 检查图片是否存在 + var picture = await dbContext.Pictures.FindAsync(pictureId); + if (picture == null) + throw new KeyNotFoundException($"找不到ID为{pictureId}的图片"); + + // 检查用户是否存在 + var user = await dbContext.Users.FindAsync(userId); + if (user == null) + throw new KeyNotFoundException($"找不到ID为{userId}的用户"); + + // 检查是否已经收藏 + var existingFavorite = await dbContext.Favorites + .FirstOrDefaultAsync(f => f.PictureId == pictureId && f.User.Id == userId); + + if (existingFavorite != null) + throw new InvalidOperationException("您已经收藏过此图片"); + + // 创建新收藏 + var favorite = new Favorite + { + PictureId = pictureId, + User = user, + CreatedAt = DateTime.UtcNow + }; + + dbContext.Favorites.Add(favorite); + await dbContext.SaveChangesAsync(); + + return true; + } + + public async Task UnfavoritePictureAsync(int pictureId, int userId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + // 查找收藏记录 + var favorite = await dbContext.Favorites + .FirstOrDefaultAsync(f => f.PictureId == pictureId && f.User.Id == userId); + + if (favorite == null) + throw new KeyNotFoundException($"未找到该图片的收藏记录"); + + // 移除收藏 + dbContext.Favorites.Remove(favorite); + await dbContext.SaveChangesAsync(); + + return true; + } + + public async Task IsPictureFavoritedByUserAsync(int pictureId, int userId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + return await dbContext.Favorites + .AnyAsync(f => f.PictureId == pictureId && f.User.Id == userId); + } +} \ No newline at end of file diff --git a/Services/QueuedHostedService.cs b/Services/QueuedHostedService.cs new file mode 100644 index 0000000..5c8b3b2 --- /dev/null +++ b/Services/QueuedHostedService.cs @@ -0,0 +1,33 @@ +using Foxel.Services.Interface; + +namespace Foxel.Services; + +public class QueuedHostedService( + ILogger logger, + IServiceProvider serviceProvider) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("后台队列处理服务已启动"); + + try + { + // 从数据库恢复未完成的任务 + using var scope = serviceProvider.CreateScope(); + var backgroundTaskQueue = scope.ServiceProvider.GetRequiredService(); + await backgroundTaskQueue.RestoreUnfinishedTasksAsync(); + logger.LogInformation("已完成未处理任务的恢复"); + } + catch (Exception ex) + { + logger.LogError(ex, "恢复未完成任务时出错"); + } + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(1000, stoppingToken); + } + logger.LogInformation("后台队列处理服务已停止"); + } +} diff --git a/Services/StorageProvider/LocalStorageProvider.cs b/Services/StorageProvider/LocalStorageProvider.cs new file mode 100644 index 0000000..34913ca --- /dev/null +++ b/Services/StorageProvider/LocalStorageProvider.cs @@ -0,0 +1,48 @@ +using Foxel.Services.Interface; + +namespace Foxel.Services.StorageProvider; + +public class LocalStorageProvider(IConfigService config) : IStorageProvider +{ + private readonly string _baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Uploads"); + private readonly string _serverUrl = config["AppSettings:ServerUrl"]; + + public async Task SaveAsync(Stream fileStream, string fileName, string contentType) + { + string currentDate = DateTime.Now.ToString("yyyy/MM"); + string folder = Path.Combine(_baseDirectory, currentDate); + Directory.CreateDirectory(folder); + + string ext = Path.GetExtension(fileName); + string newFileName = $"{Guid.NewGuid()}{ext}"; + string filePath = Path.Combine(folder, newFileName); + + await using var output = new FileStream(filePath, FileMode.Create); + await fileStream.CopyToAsync(output); + return $"/Uploads/{currentDate}/{newFileName}"; + } + + public Task DeleteAsync(string storagePath) + { + string fullPath = Path.Combine(Directory.GetCurrentDirectory(), storagePath.TrimStart('/')); + if (File.Exists(fullPath)) + File.Delete(fullPath); + return Task.CompletedTask; + } + + public string GetUrl(string storagePath) + { + return $"{_serverUrl}{storagePath}"; + } + + + public Task DownloadFileAsync(string storagePath) + { + string fullPath = Path.Combine(Directory.GetCurrentDirectory(), storagePath.TrimStart('/')); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"找不到文件: {fullPath}"); + } + return Task.FromResult(fullPath); + } +} \ No newline at end of file diff --git a/Services/StorageProvider/TelegramStorageProvider.cs b/Services/StorageProvider/TelegramStorageProvider.cs new file mode 100644 index 0000000..638f8a7 --- /dev/null +++ b/Services/StorageProvider/TelegramStorageProvider.cs @@ -0,0 +1,232 @@ +using Foxel.Services.Interface; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Foxel.Services.StorageProvider; + +public class TelegramStorageProvider(IConfigService configService) : IStorageProvider +{ + private readonly string _botToken = configService["Storage:TelegramStorageBotToken"]; + private readonly string _chatId = configService["Storage:TelegramStorageChatId"]; + private readonly string _serverUrl = configService["AppSettings:ServerUrl"]; + + public async Task SaveAsync(Stream fileStream, string fileName, string contentType) + { + using var httpClient = new HttpClient(); + using var formData = new MultipartFormDataContent(); + formData.Add(new StringContent(_chatId), "chat_id"); + var safeFileName = Path.GetFileNameWithoutExtension(fileName); + if (safeFileName.Length > 100) + safeFileName = safeFileName.Substring(0, 100); + formData.Add(new StringContent(safeFileName), "caption"); + + using var memoryStream = new MemoryStream(); + await fileStream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + var fileContent = new StreamContent(memoryStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); + + formData.Add(fileContent, "document", fileName); + + try + { + var response = + await httpClient.PostAsync($"https://api.telegram.org/bot{_botToken}/sendDocument", formData); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Telegram API 请求失败: 状态码: {response.StatusCode}, 响应: {errorContent}"); + throw new ApplicationException($"Telegram API 请求失败: {response.StatusCode}"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseContent); + if (responseObj == null || !responseObj.Ok || responseObj.Result?.Document == null) + { + throw new ApplicationException($"上传文件到 Telegram 失败: {responseContent}"); + } + + var fileId = responseObj.Result.Document.FileId; + + var metadata = new TelegramFileMetadata + { + FileId = fileId, + FileUniqueId = responseObj.Result.Document.FileUniqueId, + MessageId = responseObj.Result.MessageId, + ChatId = _chatId, + OriginalFileName = fileName, + UploadDate = DateTime.UtcNow, + MimeType = contentType + }; + return JsonSerializer.Serialize(metadata); + } + catch (Exception ex) + { + Console.WriteLine($"发送文件到 Telegram 时出错: {ex.Message}"); + throw; + } + } + + public async Task DeleteAsync(string storagePath) + { + try + { + var metadata = JsonSerializer.Deserialize(storagePath); + if (metadata == null || string.IsNullOrEmpty(metadata.ChatId) || metadata.MessageId <= 0) + { + return; + } + + using var httpClient = new HttpClient(); + var url = + $"https://api.telegram.org/bot{_botToken}/deleteMessage?chat_id={metadata.ChatId}&message_id={metadata.MessageId}"; + var response = await httpClient.GetAsync(url); + } + catch (Exception ex) + { + Console.WriteLine($"删除 Telegram 文件时出错: {ex.Message}"); + } + } + + public string GetUrl(string storagePath) + { + try + { + var metadata = JsonSerializer.Deserialize(storagePath); + if (metadata == null || string.IsNullOrEmpty(metadata.FileId)) + { + throw new ApplicationException("无效的存储路径或元数据"); + } + + return $"{_serverUrl}/api/picture/get_telegram_file?fileId={metadata.FileId}"; + } + catch (Exception ex) + { + Console.WriteLine($"生成 Telegram 文件 URL 时出错: {ex.Message}"); + return $"/images/unavailable.gif"; + } + } + + /// + /// 下载Telegram文件到临时目录 + /// + /// 存储的元数据JSON + /// 临时文件的完整路径 + public async Task DownloadFileAsync(string storagePath) + { + try + { + var metadata = JsonSerializer.Deserialize(storagePath); + if (metadata == null || string.IsNullOrEmpty(metadata.FileId)) + { + throw new ApplicationException("无效的存储路径或元数据"); + } + + using var httpClient = new HttpClient(); + var getFileUrl = $"https://api.telegram.org/bot{_botToken}/getFile?file_id={metadata.FileId}"; + var getFileResponse = await httpClient.GetAsync(getFileUrl); + + if (!getFileResponse.IsSuccessStatusCode) + { + var errorContent = await getFileResponse.Content.ReadAsStringAsync(); + throw new ApplicationException($"获取 Telegram 文件路径失败: {getFileResponse.StatusCode}, {errorContent}"); + } + + var getFileContent = await getFileResponse.Content.ReadAsStringAsync(); + var getFileResult = JsonSerializer.Deserialize(getFileContent); + if (getFileResult == null || !getFileResult.Ok || string.IsNullOrEmpty(getFileResult.Result?.FilePath)) + { + throw new ApplicationException("无法解析 Telegram 文件路径"); + } + + var filePath = getFileResult.Result.FilePath; + var fileUrl = $"https://api.telegram.org/file/bot{_botToken}/{filePath}"; + + var fileResponse = await httpClient.GetAsync(fileUrl); + if (!fileResponse.IsSuccessStatusCode) + { + throw new ApplicationException($"下载 Telegram 文件失败: {fileResponse.StatusCode}"); + } + + // 创建临时目录 + var tempDir = Path.Combine(Path.GetTempPath(), "FoxelTelegramTemp"); + if (!Directory.Exists(tempDir)) + { + Directory.CreateDirectory(tempDir); + } + + // 创建临时文件名 - 使用原始文件名或使用临时文件名 + string tempFileName = !string.IsNullOrEmpty(metadata.OriginalFileName) + ? Path.GetFileName(metadata.OriginalFileName) + : $"{Guid.NewGuid()}{Path.GetExtension(filePath)}"; + string tempFilePath = Path.Combine(tempDir, tempFileName); + + // 保存文件 + using var fileStream = await fileResponse.Content.ReadAsStreamAsync(); + using var outputStream = new FileStream(tempFilePath, FileMode.Create); + await fileStream.CopyToAsync(outputStream); + + return tempFilePath; + } + catch (Exception ex) + { + Console.WriteLine($"下载 Telegram 文件时出错: {ex.Message}"); + throw; + } + } + + // 用于处理 Telegram API 响应的辅助类 + private class TelegramResponse + { + [JsonPropertyName("ok")] public bool Ok { get; set; } + + [JsonPropertyName("result")] public TelegramResult? Result { get; set; } + } + + private class TelegramResult + { + [JsonPropertyName("message_id")] public int MessageId { get; set; } + + [JsonPropertyName("document")] public TelegramDocument? Document { get; set; } + } + + private class TelegramDocument + { + [JsonPropertyName("file_id")] public string FileId { get; set; } = string.Empty; + + [JsonPropertyName("file_unique_id")] public string FileUniqueId { get; set; } = string.Empty; + + [JsonPropertyName("file_name")] public string? FileName { get; set; } + + [JsonPropertyName("mime_type")] public string? MimeType { get; set; } + + [JsonPropertyName("file_size")] public int FileSize { get; set; } + } + + // 存储关于上传文件的元数据 + private class TelegramFileMetadata + { + public string FileId { get; set; } = string.Empty; + public string FileUniqueId { get; set; } = string.Empty; + public int MessageId { get; set; } + public string ChatId { get; set; } = string.Empty; + public string OriginalFileName { get; set; } = string.Empty; + public DateTime UploadDate { get; set; } + public string? MimeType { get; set; } + } + + private class TelegramGetFileResponse + { + [JsonPropertyName("ok")] public bool Ok { get; set; } + + [JsonPropertyName("result")] public TelegramFileResult? Result { get; set; } + } + + private class TelegramFileResult + { + [JsonPropertyName("file_path")] public string? FilePath { get; set; } + } +} \ No newline at end of file diff --git a/Services/StorageProviderFactory.cs b/Services/StorageProviderFactory.cs new file mode 100644 index 0000000..e0eda8e --- /dev/null +++ b/Services/StorageProviderFactory.cs @@ -0,0 +1,26 @@ +using Foxel.Models.DataBase; +using Foxel.Services.Interface; +using Foxel.Services.StorageProvider; +using Microsoft.Extensions.DependencyInjection; + +namespace Foxel.Services; + +public class StorageProviderFactory : IStorageProviderFactory +{ + private readonly IServiceProvider _serviceProvider; + + public StorageProviderFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IStorageProvider GetProvider(StorageType storageType) + { + return storageType switch + { + StorageType.Local => _serviceProvider.GetRequiredService(), + StorageType.Telegram => _serviceProvider.GetRequiredService(), + _ => throw new ArgumentOutOfRangeException(nameof(storageType), $"不支持的存储类型: {storageType}") + }; + } +} diff --git a/Services/TagService.cs b/Services/TagService.cs new file mode 100644 index 0000000..ec921fd --- /dev/null +++ b/Services/TagService.cs @@ -0,0 +1,233 @@ +using Microsoft.EntityFrameworkCore; +using Foxel.Models; +using Foxel.Models.DataBase; +using Foxel.Models.Response.Tag; +using Foxel.Services.Interface; + +namespace Foxel.Services; + +public class TagService(IDbContextFactory contextFactory) : ITagService +{ + public async Task> GetFilteredTagsAsync( + int page = 1, + int pageSize = 20, + string? searchQuery = null, + string? sortBy = "pictureCount", + string? sortDirection = "desc", + int? minPictureCount = null) + { + try + { + if (page < 1) page = 1; + if (pageSize < 1 || pageSize > 100) pageSize = 20; + + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + // 构建基础查询,确保加载图片关联 + IQueryable query = dbContext.Tags.Include(t => t.Pictures); + + // 应用搜索条件 + if (!string.IsNullOrWhiteSpace(searchQuery)) + { + var searchTerm = searchQuery.ToLower(); + query = query.Where(t => + t.Name.ToLower().Contains(searchTerm) || + (t.Description != null && t.Description.ToLower().Contains(searchTerm))); + } + + // 应用最小图片数量过滤(安全处理Pictures集合) + if (minPictureCount.HasValue && minPictureCount.Value > 0) + { + query = query.Where(t => t.Pictures != null && t.Pictures.Count >= minPictureCount.Value); + } + + // 获取总记录数(先计算总数再排序和分页) + var totalCount = await query.CountAsync(); + + // 没有结果时返回空列表 + if (totalCount == 0) + { + return new PaginatedResult + { + Data = new List(), + TotalCount = 0, + Page = page, + PageSize = pageSize + }; + } + + // 应用排序 + query = ApplySorting(query, sortBy, sortDirection); + + // 应用分页 + var tags = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + // 转换为响应格式,确保包含图片数量 + var tagResponses = new List(); + + foreach (var tag in tags) + { + tagResponses.Add(new TagResponse + { + Id = tag.Id, + Name = tag.Name, + Description = tag.Description, + CreatedAt = tag.CreatedAt, + PictureCount = tag.Pictures?.Count ?? 0 // 确保包含图片数量 + }); + } + + return new PaginatedResult + { + Data = tagResponses, + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + catch (Exception ex) + { + // 记录详细错误信息 + Console.WriteLine($"GetFilteredTagsAsync error: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + + throw; + } + } + + // 添加排序方法 + private static IQueryable ApplySorting( + IQueryable query, + string? sortBy, + string? sortDirection) + { + var isAscending = string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase); + + return sortBy?.ToLower() switch + { + "name" => isAscending + ? query.OrderBy(t => t.Name) + : query.OrderByDescending(t => t.Name), + + "createdat" => isAscending + ? query.OrderBy(t => t.CreatedAt) + : query.OrderByDescending(t => t.CreatedAt), + + "picturecount" => isAscending + ? query.OrderBy(t => t.Pictures.Count) + : query.OrderByDescending(t => t.Pictures.Count), + + _ => query.OrderByDescending(t => t.Pictures.Count) // 默认按图片数量降序排列 + }; + } + + public async Task GetTagByIdAsync(int id) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var tag = await dbContext.Tags + .Include(t => t.Pictures) + .FirstOrDefaultAsync(t => t.Id == id); + + if (tag == null) + throw new KeyNotFoundException($"找不到ID为{id}的标签"); + + return new TagResponse + { + Id = tag.Id, + Name = tag.Name, + Description = tag.Description, + CreatedAt = tag.CreatedAt, + PictureCount = tag.Pictures?.Count ?? 0 + }; + } + + public async Task CreateTagAsync(string name, string? description = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("标签名称不能为空"); + + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + // 检查是否已存在同名标签 + var existingTag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Name.ToLower() == name.ToLower()); + if (existingTag != null) + throw new InvalidOperationException("已存在相同名称的标签"); + + var tag = new Tag + { + Name = name.Trim(), + Description = description?.Trim(), + CreatedAt = DateTime.UtcNow, + Pictures = new List() // 初始化为空集合而不是null + }; + + dbContext.Tags.Add(tag); + await dbContext.SaveChangesAsync(); + + return new TagResponse + { + Id = tag.Id, + Name = tag.Name, + Description = tag.Description, + CreatedAt = tag.CreatedAt, + PictureCount = 0 + }; + } + + public async Task UpdateTagAsync(int id, string? name = null, string? description = null) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var tag = await dbContext.Tags.FindAsync(id); + if (tag == null) + throw new KeyNotFoundException($"找不到ID为{id}的标签"); + + if (!string.IsNullOrWhiteSpace(name)) + { + // 检查是否已存在同名标签(不包括当前标签) + var existingTag = + await dbContext.Tags.FirstOrDefaultAsync(t => t.Id != id && t.Name.ToLower() == name.ToLower()); + + if (existingTag != null) + throw new InvalidOperationException("已存在相同名称的标签"); + + tag.Name = name.Trim(); + } + + if (description != null) // 允许设置为空字符串 + { + tag.Description = description.Trim(); + } + + tag.UpdatedAt = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(); + + return new TagResponse + { + Id = tag.Id, + Name = tag.Name, + Description = tag.Description, + CreatedAt = tag.CreatedAt, + PictureCount = tag.Pictures?.Count ?? 0 + }; + } + + public async Task DeleteTagAsync(int id) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var tag = await dbContext.Tags.FindAsync(id); + if (tag == null) + throw new KeyNotFoundException($"找不到ID为{id}的标签"); + + dbContext.Tags.Remove(tag); + await dbContext.SaveChangesAsync(); + + return true; + } +} \ No newline at end of file diff --git a/Services/UserService.cs b/Services/UserService.cs new file mode 100644 index 0000000..65a1080 --- /dev/null +++ b/Services/UserService.cs @@ -0,0 +1,139 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Foxel.Models.DataBase; +using Foxel.Services.Interface; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Foxel.Models.Request; +using Foxel.Models.Request.Auth; +using static Foxel.Utils.AuthHelper; + +namespace Foxel.Services; + +public class UserService(IDbContextFactory dbContextFactory, IConfigService configuration) + : IUserService +{ + public async Task<(bool success, string message, User? user)> RegisterUserAsync(RegisterRequest request) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var existingUser = await context.Users.FirstOrDefaultAsync(u => u.Email == request.Email); + if (existingUser != null) + { + return (false, "该邮箱已被注册", null); + } + + existingUser = await context.Users.FirstOrDefaultAsync(u => u.UserName == request.UserName); + if (existingUser != null) + { + return (false, "该用户名已被使用", null); + } + + var user = new User + { + UserName = request.UserName, + Email = request.Email, + PasswordHash = HashPassword(request.Password), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + return (true, "用户注册成功", user); + } + + public async Task<(bool success, string message, User? user)> AuthenticateUserAsync(LoginRequest request) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var user = await context.Users.Include(x => x.Role).FirstOrDefaultAsync(u => u.Email == request.Email); + + if (user == null) + { + return (false, "用户不存在", null); + } + + if (!VerifyPassword(request.Password, user.PasswordHash)) + { + return (false, "密码错误", null); + } + + return (true, "登录成功", user); + } + + public Task GenerateJwtTokenAsync(User user) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Email, user.Email), + new(ClaimTypes.Name, user.UserName) + }; + if (user.Role != null) + { + claims.Add(new Claim(ClaimTypes.Role, user.Role.Name)); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"] ?? + throw new InvalidOperationException( + "JWT Secret key not found"))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expires = DateTime.UtcNow.AddYears(1); + var token = new JwtSecurityToken( + issuer: configuration["Jwt:Issuer"], + audience: configuration["Jwt:Audience"], + claims: claims, + expires: expires, + signingCredentials: creds + ); + + var tokenString = new JwtSecurityTokenHandler().WriteToken(token); + return Task.FromResult(tokenString); + } + + public async Task GetUserByIdAsync(int userId) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + return await context.Users.Include(x => x.Role).FirstOrDefaultAsync(u => u.Id == userId); + } + + public async Task<(bool success, string message, User? user)> FindOrCreateGitHubUserAsync( + string githubId, string githubLogin, string? email) + { + if (string.IsNullOrEmpty(email)) + { + return (false, "GitHub账号未提供邮箱地址", null); + } + + await using var context = await dbContextFactory.CreateDbContextAsync(); + + // 先尝试通过邮箱查找用户 + var user = await context.Users.FirstOrDefaultAsync(u => u.Email == email); + + if (user == null) + { + // 用户不存在,创建新用户 + user = new User + { + UserName = $"{githubLogin}_{githubId.Substring(0, 5)}", // 创建唯一用户名 + Email = email, + PasswordHash = HashPassword(Guid.NewGuid().ToString()), + GithubId = githubId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + return (true, "GitHub用户注册成功", user); + } + else + { + if (string.IsNullOrEmpty(user.GithubId)) + { + user.GithubId = githubId; + user.UpdatedAt = DateTime.UtcNow; + await context.SaveChangesAsync(); + } + return (true, "GitHub用户登录成功", user); + } + } +} \ No newline at end of file diff --git a/Utils/AiHelper.cs b/Utils/AiHelper.cs new file mode 100644 index 0000000..a73a3fe --- /dev/null +++ b/Utils/AiHelper.cs @@ -0,0 +1,104 @@ +namespace Foxel.Utils; + +public static class AiHelper +{ + /// + /// 从AI响应中提取标题和描述 + /// + /// AI生成的响应文本 + /// 包含标题和描述的元组 + public static (string title, string description) ExtractTitleAndDescription(string aiResponse) + { + string title = "AI生成的标题"; + string description = "AI生成的描述"; + + try + { + // 尝试解析JSON响应 + if (aiResponse.Contains("{") && aiResponse.Contains("}")) + { + // 提取JSON部分 + int jsonStartIndex = aiResponse.IndexOf('{'); + int jsonEndIndex = aiResponse.LastIndexOf('}') + 1; + + if (jsonStartIndex >= 0 && jsonEndIndex > jsonStartIndex) + { + string jsonPart = aiResponse[jsonStartIndex..jsonEndIndex]; + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + try + { + var result = System.Text.Json.JsonSerializer.Deserialize(jsonPart, options); + if (result != null) + { + if (!string.IsNullOrWhiteSpace(result.Title)) + title = result.Title; + + if (!string.IsNullOrWhiteSpace(result.Description)) + description = result.Description; + + return (title, description); + } + } + catch (System.Text.Json.JsonException) + { + // JSON解析失败,继续尝试文本解析 + } + } + } + + // 回退到文本解析逻辑 + var titleMarker = "标题:"; + var descMarker = "描述:"; + + var titleIndex = aiResponse.IndexOf(titleMarker, StringComparison.Ordinal); + var descIndex = aiResponse.IndexOf(descMarker, StringComparison.Ordinal); + + if (titleIndex >= 0 && descIndex > titleIndex) + { + titleIndex += titleMarker.Length; + var titleEndIndex = descIndex; + title = aiResponse[titleIndex..titleEndIndex].Trim(); + + descIndex += descMarker.Length; + description = aiResponse[descIndex..].Trim(); + } + else if (titleIndex >= 0) + { + titleIndex += titleMarker.Length; + title = aiResponse[titleIndex..].Trim(); + } + else if (descIndex >= 0) + { + descIndex += descMarker.Length; + description = aiResponse[descIndex..].Trim(); + } + else + { + description = aiResponse.Trim(); + } + } + catch (Exception ex) + { + Console.WriteLine($"解析AI响应时出错: {ex.Message}"); + description = $"原始AI响应: {aiResponse}"; + } + + return (title, description); + } + + // 用于解析JSON的类 + public class ImageAnalysisResult + { + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + } + + public class TagsResult + { + public string[] Tags { get; set; } = Array.Empty(); + } +} diff --git a/Utils/AuthHelper.cs b/Utils/AuthHelper.cs new file mode 100644 index 0000000..94bc426 --- /dev/null +++ b/Utils/AuthHelper.cs @@ -0,0 +1,19 @@ +using System.Text; +namespace Foxel.Utils; +using System.Security.Cryptography; + +public static class AuthHelper +{ + public static string HashPassword(string password) + { + using var sha256 = SHA256.Create(); + var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password)); + return Convert.ToBase64String(hashedBytes); + } + + public static bool VerifyPassword(string password, string storedHash) + { + var hashedPassword = HashPassword(password); + return hashedPassword == storedHash; + } +} \ No newline at end of file diff --git a/Utils/ImageHelper.cs b/Utils/ImageHelper.cs new file mode 100644 index 0000000..dcb35f3 --- /dev/null +++ b/Utils/ImageHelper.cs @@ -0,0 +1,345 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using System.Globalization; +using Foxel.Models; +using SixLabors.ImageSharp.PixelFormats; + +namespace Foxel.Utils; + +/// +/// 图片处理工具类 +/// +public static class ImageHelper +{ + /// + /// 获取完整URL路径 + /// + /// 服务器URL + /// 相对路径 + /// 完整URL路径 + public static string GetFullPath(string serverUrl, string relativePath) + { + if (string.IsNullOrEmpty(relativePath)) + return string.Empty; + if (relativePath.StartsWith("https://")) + return relativePath; + return $"{serverUrl.TrimEnd('/')}{relativePath}"; + } + + /// + /// 创建缩略图 + /// + /// 原始图片路径 + /// 缩略图保存路径 + /// 缩略图宽度 + /// 压缩质量(1-100) + /// 生成的缩略图的文件大小(字节) + public static async Task CreateThumbnailAsync(string originalPath, string thumbnailPath, int width, + int quality = 75) + { + // 获取原始文件大小 + var originalFileInfo = new FileInfo(originalPath); + long originalSize = originalFileInfo.Length; + + using var image = await Image.LoadAsync(originalPath); + + // 去除EXIF元数据以减小文件大小 + image.Metadata.ExifProfile = null; + + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(width, 0), + Mode = ResizeMode.Max + })); + + string extension = Path.GetExtension(thumbnailPath).ToLower(); + + // 根据原图大小动态调整质量 + int adjustedQuality = AdjustQualityByFileSize(originalSize, extension, quality); + + if (extension == ".jpg" || extension == ".jpeg") + { + await image.SaveAsJpegAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder + { + Quality = adjustedQuality + }); + } + else if (extension == ".png") + { + await image.SaveAsPngAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder + { + CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression, + ColorType = SixLabors.ImageSharp.Formats.Png.PngColorType.RgbWithAlpha, // 确保使用最优的颜色类型 + FilterMethod = SixLabors.ImageSharp.Formats.Png.PngFilterMethod.Adaptive // 使用自适应过滤 + }); + } + else + { + await image.SaveAsync(thumbnailPath); + } + + var thumbnailFileInfo = new FileInfo(thumbnailPath); + if (thumbnailFileInfo.Length < originalSize) return thumbnailFileInfo.Length; + + // 再次尝试优化,但不改变扩展名 + if (extension == ".png") + { + await image.SaveAsPngAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Png.PngEncoder + { + CompressionLevel = SixLabors.ImageSharp.Formats.Png.PngCompressionLevel.BestCompression, + FilterMethod = SixLabors.ImageSharp.Formats.Png.PngFilterMethod.Adaptive + }); + thumbnailFileInfo = new FileInfo(thumbnailPath); + } + else if (extension == ".jpg" || extension == ".jpeg") + { + // 如果是 JPEG,尝试降低质量进一步压缩 + await image.SaveAsJpegAsync(thumbnailPath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder + { + Quality = Math.Max(adjustedQuality - 10, 60) // 再降低质量但不低于60 + }); + thumbnailFileInfo = new FileInfo(thumbnailPath); + } + + return thumbnailFileInfo.Length; + } + + /// + /// 检查图像是否包含透明像素 + /// + /// 要检查的图像 + /// 如果图像包含透明像素则返回true + private static bool HasTransparency(Image image) + { + // 检查图像格式是否支持透明度 + if (image.PixelType.AlphaRepresentation == PixelAlphaRepresentation.None) + { + return false; // 图像格式不支持透明度 + } + + // 对于小图片,逐像素检查是否有透明度 + if (image.Width * image.Height <= 1000 * 1000) // 对于不超过1000x1000的图片 + { + using var imageWithAlpha = image.CloneAs(); + + for (int y = 0; y < imageWithAlpha.Height; y++) + { + for (int x = 0; x < imageWithAlpha.Width; x++) + { + if (imageWithAlpha[x, y].A < 255) + { + return true; + } + } + } + + return false; + } + else + { + using var imageWithAlpha = image.CloneAs(); + int sampleSize = Math.Max(image.Width, image.Height) / 100; + sampleSize = Math.Max(1, sampleSize); + + for (int y = 0; y < imageWithAlpha.Height; y += sampleSize) + { + for (int x = 0; x < imageWithAlpha.Width; x += sampleSize) + { + if (imageWithAlpha[x, y].A < 255) + { + return true; + } + } + } + + return false; + } + } + + /// + /// 根据原始文件大小调整质量参数 + /// + private static int AdjustQualityByFileSize(long originalSize, string extension, int baseQuality) + { + if (extension == ".jpg" || extension == ".jpeg") + { + if (originalSize > 10 * 1024 * 1024) // 10MB + return Math.Min(baseQuality, 65); + else if (originalSize > 5 * 1024 * 1024) // 5MB + return Math.Min(baseQuality, 70); + else if (originalSize > 1 * 1024 * 1024) // 1MB + return Math.Min(baseQuality, 75); + } + + return baseQuality; + } + + /// + /// 将图片转换为Base64编码 + /// + /// 图片路径 + /// Base64编码字符串 + public static async Task ConvertImageToBase64(string imagePath) + { + byte[] imageBytes = await File.ReadAllBytesAsync(imagePath); + return Convert.ToBase64String(imageBytes); + } + + /// + /// 提取图片的EXIF信息 + /// + /// 图片路径 + /// EXIF信息对象 + public static async Task ExtractExifInfoAsync(string imagePath) + { + var exifInfo = new ExifInfo(); + + try + { + // 确保文件存在 + if (!File.Exists(imagePath)) + { + exifInfo.ErrorMessage = "找不到图片文件"; + return exifInfo; + } + + // 使用ImageSharp读取EXIF信息 + using var image = await Image.LoadAsync(imagePath); + var exifProfile = image.Metadata.ExifProfile; + + // 添加基本图像信息 + exifInfo.Width = image.Width; + exifInfo.Height = image.Height; + + if (exifProfile != null) + { + // 提取相机信息 + if (exifProfile.TryGetValue(ExifTag.Make, out var make)) + exifInfo.CameraMaker = make.Value; + + if (exifProfile.TryGetValue(ExifTag.Model, out var model)) + exifInfo.CameraModel = model.Value; + + if (exifProfile.TryGetValue(ExifTag.Software, out var software)) + exifInfo.Software = software.Value; + + // 提取拍摄参数 + if (exifProfile.TryGetValue(ExifTag.ExposureTime, out var exposureTime)) + exifInfo.ExposureTime = exposureTime.Value.ToString(); + + if (exifProfile.TryGetValue(ExifTag.FNumber, out var fNumber)) + exifInfo.Aperture = $"f/{fNumber.Value}"; + + if (exifProfile.TryGetValue(ExifTag.ISOSpeedRatings, out var iso)) + { + if (iso.Value is { Length: > 0 } isoArray) + { + exifInfo.IsoSpeed = isoArray[0].ToString(); + } + else + { + exifInfo.IsoSpeed = iso.Value?.ToString(); + } + } + + if (exifProfile.TryGetValue(ExifTag.FocalLength, out var focalLength)) + exifInfo.FocalLength = $"{focalLength.Value}mm"; + + if (exifProfile.TryGetValue(ExifTag.Flash, out var flash)) + exifInfo.Flash = flash.Value.ToString(); + + if (exifProfile.TryGetValue(ExifTag.MeteringMode, out var meteringMode)) + exifInfo.MeteringMode = meteringMode.Value.ToString(); + + if (exifProfile.TryGetValue(ExifTag.WhiteBalance, out var whiteBalance)) + exifInfo.WhiteBalance = whiteBalance.Value.ToString(); + + // 提取时间信息并确保存储为字符串 + if (exifProfile.TryGetValue(ExifTag.DateTimeOriginal, out var dateTime)) + { + exifInfo.DateTimeOriginal = dateTime.Value; + + // 解析日期时间 + if (DateTime.TryParseExact(dateTime.Value, "yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture, + DateTimeStyles.None, out _)) + { + // 只在ExifInfo中保留原始字符串格式 + } + } + + // 提取GPS信息 + if (exifProfile.TryGetValue(ExifTag.GPSLatitude, out var latitude) && + exifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out var latitudeRef)) + { + string? latRef = latitudeRef.Value; + exifInfo.GpsLatitude = ConvertGpsCoordinateToString(latitude.Value, latRef == "S"); + } + + if (exifProfile.TryGetValue(ExifTag.GPSLongitude, out var longitude) && + exifProfile.TryGetValue(ExifTag.GPSLongitudeRef, out var longitudeRef)) + { + string? longRef = longitudeRef.Value; + exifInfo.GpsLongitude = ConvertGpsCoordinateToString(longitude.Value, longRef == "W"); + } + } + } + catch (Exception ex) + { + exifInfo.ErrorMessage = $"提取EXIF信息时出错: {ex.Message}"; + } + + return exifInfo; + } + + /// + /// 将GPS坐标转换为字符串表示 + /// + /// GPS坐标的有理数数组(度、分、秒) + /// 是否为负值(南纬/西经) + /// 十进制格式的GPS坐标 + private static string? ConvertGpsCoordinateToString(Rational[]? rationals, bool isNegative) + { + if (rationals == null || rationals.Length < 3) + return null; + + try + { + // 度分秒转换为十进制度 + double degrees = rationals[0].Numerator / (double)rationals[0].Denominator; + double minutes = rationals[1].Numerator / (double)rationals[1].Denominator; + double seconds = rationals[2].Numerator / (double)rationals[2].Denominator; + + double coordinate = degrees + (minutes / 60) + (seconds / 3600); + + // 如果是南纬或西经,则为负值 + if (isNegative) + coordinate = -coordinate; + + return coordinate.ToString(CultureInfo.InvariantCulture); + } + catch + { + return null; + } + } + + /// + /// 从EXIF信息中解析拍摄时间 + /// + /// EXIF中的拍摄时间字符串 + /// UTC格式的日期时间,如果解析失败则返回null + public static DateTime? ParseExifDateTime(string? dateTimeOriginal) + { + if (string.IsNullOrEmpty(dateTimeOriginal)) + return null; + + if (DateTime.TryParseExact(dateTimeOriginal, "yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsedDate)) + { + return DateTime.SpecifyKind(parsedDate, DateTimeKind.Local).ToUniversalTime(); + } + + return null; + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..8a56b40 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "" +} \ No newline at end of file