From de92fe3a7f3bf576a2726e82e1051febe5eb72ab Mon Sep 17 00:00:00 2001 From: "seonkyu.kim" Date: Tue, 25 Feb 2025 22:39:51 +0900 Subject: [PATCH] =?UTF-8?q?[=F0=9F=91=B7=F0=9F=8F=BB]=20git=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=A7=84=ED=96=89=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Back.csproj | 25 ++ Back.http | 6 + Jenkinsfile | 59 ++++ Program.cs | 153 ++++++++++ Program/Common/Chat/ChatHub.cs | 37 +++ Program/Common/Data/AppDbContext.cs | 35 +++ Program/Common/JWTToken/JwtTokenService.cs | 116 ++++++++ Program/Common/Model/JwtSettings.cs | 52 ++++ Program/Common/Model/Status.cs | 78 ++++++ Program/V1/Controllers/AppController.cs | 68 +++++ Program/V1/Controllers/ErrorController.cs | 15 + Program/V1/Controllers/MemberController.cs | 43 +++ Program/V1/Controllers/PushController.cs | 105 +++++++ Program/V1/Controllers/UserController.cs | 311 +++++++++++++++++++++ Program/V1/Models/APIResult.cs | 15 + Program/V1/Models/Academy.cs | 31 ++ Program/V1/Models/PushPayload.cs | 41 +++ Program/V1/Models/User.cs | 150 ++++++++++ Program/V1/Models/Version.cs | 21 ++ Program/V1/Repositories/UserRepository.cs | 28 ++ Program/V1/Services/PushService.cs | 126 +++++++++ Program/V1/Services/RepositoryService.cs | 136 +++++++++ Program/V1/Services/UserService.cs | 7 + Properties/launchSettings.json | 41 +++ README.md | 16 ++ SwaggerConfigure.cs | 92 ++++++ appsettings.Development.json | 8 + appsettings.json | 9 + 28 files changed, 1824 insertions(+) create mode 100644 Back.csproj create mode 100644 Back.http create mode 100644 Jenkinsfile create mode 100644 Program.cs create mode 100644 Program/Common/Chat/ChatHub.cs create mode 100644 Program/Common/Data/AppDbContext.cs create mode 100644 Program/Common/JWTToken/JwtTokenService.cs create mode 100644 Program/Common/Model/JwtSettings.cs create mode 100644 Program/Common/Model/Status.cs create mode 100644 Program/V1/Controllers/AppController.cs create mode 100644 Program/V1/Controllers/ErrorController.cs create mode 100644 Program/V1/Controllers/MemberController.cs create mode 100644 Program/V1/Controllers/PushController.cs create mode 100644 Program/V1/Controllers/UserController.cs create mode 100644 Program/V1/Models/APIResult.cs create mode 100644 Program/V1/Models/Academy.cs create mode 100644 Program/V1/Models/PushPayload.cs create mode 100644 Program/V1/Models/User.cs create mode 100644 Program/V1/Models/Version.cs create mode 100644 Program/V1/Repositories/UserRepository.cs create mode 100644 Program/V1/Services/PushService.cs create mode 100644 Program/V1/Services/RepositoryService.cs create mode 100644 Program/V1/Services/UserService.cs create mode 100644 Properties/launchSettings.json create mode 100644 README.md create mode 100644 SwaggerConfigure.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json diff --git a/Back.csproj b/Back.csproj new file mode 100644 index 0000000..f7dec04 --- /dev/null +++ b/Back.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/Back.http b/Back.http new file mode 100644 index 0000000..ebc2035 --- /dev/null +++ b/Back.http @@ -0,0 +1,6 @@ +@Back_HostAddress = http://localhost:5144 + +GET {{Back_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..399a767 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,59 @@ +pipeline { + agent any + environment { + DOCKER_RELEASE_CONTAINER = 'acamate-back-build-release' + DOCKER_DEBUG_CONTAINER = 'acamate-back-build-debug' + DOCKER_RELEASE_RUN_CONTAINER = 'acamate-run-release' + DOCKER_DEBUG_RUN_CONTAINER = 'acamate-run-debug' + + APP_VOLUME = '/src' + } + stages { + stage('Clone Repository') { + steps { + git url: 'https://git.ipstein.myds.me/AcaMate/AcaMate_API.git', branch: env.GIT_BRANCH + } + } + stage('Deploy') { + steps { + script { + if (env.GIT_BRANCH == 'main') { + // main 브랜치용 작업 + def containerId = sh(script: "docker ps -qf 'name=${DOCKER_RELEASE_RUN_CONTAINER}'", returnStdout: true).trim() + if (containerId) { + sh "docker cp ${WORKSPACE}/. ${containerId}:${APP_VOLUME}" + sh "docker start ${DOCKER_RELEASE_CONTAINER}" + } else { + error "Docker container ${DOCKER_RELEASE_RUN_CONTAINER} not found" + } + } else if (env.GIT_BRANCH == 'debug') { + // debug 브랜치용 작업 + def containerId = sh(script: "docker ps -qf 'name=${DOCKER_DEBUG_RUN_CONTAINER}'", returnStdout: true).trim() + if (containerId) { + sh "docker cp ${WORKSPACE}/. ${containerId}:${APP_VOLUME}" + sh "docker start ${DOCKER_DEBUG_CONTAINER}" + } else { + error "Docker container ${DOCKER_DEBUG_RUN_CONTAINER} not found" + } + } + } + } + } + } + + post { + always { + script { + def containerId = sh(script: "docker ps -qf 'name=${env.GIT_BRANCH == 'main' ? DOCKER_RELEASE_CONTAINER : DOCKER_DEBUG_CONTAINER}'", returnStdout: true).trim() + if (containerId) { + sh "docker logs ${containerId}" + } else { + echo "Docker container not found" + } + } + } + failure { + echo "Build failed. Check the console output for details." + } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..3b7eed2 --- /dev/null +++ b/Program.cs @@ -0,0 +1,153 @@ +using Pomelo.EntityFrameworkCore; +using System.Text; +using AcaMate.Common.Chat; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.SignalR; +using AcaMate.Common.Models; +using AcaMate.V1.Services; +using AcaMate.Common.Data; +using AcaMate.V1.Controllers; + + +var builder = WebApplication.CreateBuilder(args); + + +// DB 설정부 시작 +builder.Configuration.AddJsonFile("private/dbSetting.json", optional: true, reloadOnChange: true); +// var connectionString = builder.Configuration.GetConnectionString("MariaDbConnection"); +// builder.Services.AddDbContext(options => options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))); +builder.Services.AddDbContext(optionsAction: (serviceProvider, options) => +{ + var httpContextAccessor = serviceProvider.GetRequiredService(); + var dbName = httpContextAccessor.HttpContext?.Request.Query["aca_code"].ToString(); + var baseConnectionString = builder.Configuration.GetConnectionString("MariaDbConnection"); + if (!string.IsNullOrEmpty(dbName)) + { + baseConnectionString = baseConnectionString.Replace("database=AcaMate", $"database={dbName}"); + } + + options.UseMySql(baseConnectionString, ServerVersion.AutoDetect(baseConnectionString)); +}); +builder.Services.AddHttpContextAccessor(); +// DB 설정부 끝 + + +var dbString = builder.Configuration.GetConnectionString("MariaDbConnection"); +var userString = builder.Configuration.GetConnectionString("DBAccount"); + +// JWT 설정부 시작 +if (builder.Environment.IsDevelopment()) +{ + builder.Configuration.AddJsonFile("private/jwtSetting.Development.json", optional: true, reloadOnChange: true); +} +else +{ + builder.Configuration.AddJsonFile("private/jwtSetting.json", optional: true, reloadOnChange: true); +} + +builder.Services.Configure(builder.Configuration.GetSection("JwtSettings")); + +builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get(); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey)), + ClockSkew = TimeSpan.FromMinutes(jwtSettings.ClockSkewMinutes) + }; + }); +// JWT 설정부 끝 + +builder.Services.AddControllers(); + +// 여기다가 API 있는 컨트롤러들 AddScoped 하면 되는건가? +builder.Services.AddScoped(); +builder.Services.AddScoped(); +// builder.Services.AddScoped(); // +// builder.Services.AddScoped(); + +builder.Services.AddEndpointsApiExplorer(); + +// 스웨거 설정 추가 부분 +// builder.Services.AddSwaggerGen(); +builder.Services.AddCustomSwagger(); + +// SignalR 설정 추가 부분 +builder.Services.AddSignalR(); +builder.Services.AddCors(option => +{ + option.AddPolicy("CorsPolicy", builder => + { + builder + .WithOrigins("https://devacamate.ipstein.myds.me", "https://acamate.ipstein.myds.me") // 특정 도메인만 허용 + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + + }); +}); + +// 로그 설정 부분 +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +if (builder.Environment.IsDevelopment()) { + builder.Logging.SetMinimumLevel(LogLevel.Trace); +} +else +{ + builder.Logging.SetMinimumLevel(LogLevel.Warning); +} + + + +// 로컬 테스트 위한 부분 (올릴때는 꺼두기) +// builder.WebHost.UseUrls("http://0.0.0.0:5144"); + +///// ===== builder 설정 부 ===== ///// + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + // app.UseSwagger(); + // app.UseSwaggerUI(); + app.UseCustomSwaggerUI(); + app.UseDeveloperExceptionPage(); // 좀더 자세한 예외 정보 제공 +} +else +{ + app.UseExceptionHandler("/error"); + app.UseHsts(); +} + +// 로컬 테스트 위한 부분 (올릴떄는 켜두기) +app.UseHttpsRedirection(); + +app.UseRouting(); +// app.MapControllers(); + +app.UseCors("CorsPolicy"); +app.UseAuthorization(); + +app.UseWebSockets(); +app.UseEndpoints(end => +{ + end.MapControllers(); + end.MapHub("/chatHub"); +}); + + +app.Run(); \ No newline at end of file diff --git a/Program/Common/Chat/ChatHub.cs b/Program/Common/Chat/ChatHub.cs new file mode 100644 index 0000000..9841c96 --- /dev/null +++ b/Program/Common/Chat/ChatHub.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.SignalR; +using System.Threading.Tasks; + +namespace AcaMate.Common.Chat; + +public class ChatHub : Hub +{ + // 클라이언트에서 메시지를 보내면 모든 사용자에게 전송 + public async Task SendMessage(string user, string message) + { + Console.WriteLine($"Message received: {user}: {message}"); + await Clients.All.SendAsync("ReceiveMessage", user, message); + + } + + // 특정 사용자에게 메시지를 보냄 + public async Task SendMessageToUser(string connectionId, string message) + { + await Clients.Client(connectionId).SendAsync("ReceiveMessage", message); + } + + // 클라이언트가 연결될 때 호출 + public override async Task OnConnectedAsync() + { + await Clients.Caller.SendAsync("ReceiveMessage", "System", $"Welcome! Your ID: {Context.ConnectionId}"); + Console.WriteLine("OnConnectedAsync"); + await base.OnConnectedAsync(); + } + + // 클라이언트가 연결 해제될 때 호출 + public override async Task OnDisconnectedAsync(Exception? exception) + { + await Clients.All.SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} disconnected"); + Console.WriteLine("OnDisconnectedAsync"); + await base.OnDisconnectedAsync(exception); + } +} \ No newline at end of file diff --git a/Program/Common/Data/AppDbContext.cs b/Program/Common/Data/AppDbContext.cs new file mode 100644 index 0000000..3fddb4f --- /dev/null +++ b/Program/Common/Data/AppDbContext.cs @@ -0,0 +1,35 @@ +using AcaMate.Common.Models; +using Microsoft.EntityFrameworkCore; +using AcaMate.V1.Models; +using Version = AcaMate.V1.Models.Version; + +namespace AcaMate.Common.Data; +//database=AcaMate; +public class AppDbContext: DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + //MARK: Program + public DbSet Version { get; set; } + public DbSet Academy { get; set; } + public DbSet RefreshTokens { get; set; } + + //MARK: USER + public DbSet Login { get; set; } + public DbSet UserAcademy { get; set; } + public DbSet User { get; set; } + public DbSet Permission { get; set; } + // public DbSet Token { get; set; } + public DbSet Location { get; set; } + public DbSet Contact { get; set; } + + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(ua => new { ua.uid, ua.bid }); + } +} \ No newline at end of file diff --git a/Program/Common/JWTToken/JwtTokenService.cs b/Program/Common/JWTToken/JwtTokenService.cs new file mode 100644 index 0000000..250fc0e --- /dev/null +++ b/Program/Common/JWTToken/JwtTokenService.cs @@ -0,0 +1,116 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Collections.Generic; +using AcaMate.Common.Models; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; + + +using System.Security.Cryptography; + +namespace AcaMate.Common.Token; + +public class JwtTokenService +{ + private readonly JwtSettings _jwtSettings; + + private readonly ILogger _logger; + + public JwtTokenService(IOptions jwtSettings, ILogger logger) + { + _jwtSettings = jwtSettings.Value; + _logger = logger; + } + + public string GenerateJwtToken(string uid)//, string role) + { + // 1. 클레임(Claim) 설정 - 필요에 따라 추가 정보도 포함 + var claims = new List + { + // 토큰 주체(sub) 생성을 위해 값으로 uid를 사용함 : 토큰이 대표하는 고유 식별자 + new Claim(JwtRegisteredClaimNames.Sub, uid), + // Jti 는 토큰 식별자로 토큰의 고유 ID 이다. + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + // jwt 토큰이 가지는 권한 + // new Claim(ClaimTypes.Role, role), + // 추가 클레임 예: new Claim(ClaimTypes.Role, "Admin") + }; + + // 2. 비밀 키와 SigningCredentials 생성 + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + // 3. 토큰 생성 (Issuer, Audience, 만료 시간, 클레임, 서명 정보 포함) + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + claims: claims, + expires: DateTime.Now.AddMinutes(_jwtSettings.ExpiryMinutes), + signingCredentials: credentials + ); + + // 4. 토큰 객체를 문자열로 변환하여 반환 + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public RefreshToken GenerateRefreshToken(string uid) + { + var randomNumber = new byte[32]; // 256비트 + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomNumber); + } + + // return Convert.ToBase64String(randomNumber); + return new RefreshToken() + { + uid = uid, + refresh_token = Convert.ToBase64String(randomNumber), + create_Date = DateTime.UtcNow, + expire_date = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays) + + }; + } + + + public ClaimsPrincipal ValidateToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) return null; + var tokenHandler = new JwtSecurityTokenHandler(); + + try + { + var key = Encoding.UTF8.GetBytes(_jwtSettings.SecretKey); + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidIssuer = _jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = _jwtSettings.Audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(_jwtSettings.ClockSkewMinutes) + }; + var principal = tokenHandler.ValidateToken(token, validationParameters, out var securityToken); + return principal; + } + catch (Exception e) + { + Console.WriteLine($"검증 실패 {e}"); + return null; + } + + + } + + + + + + +} \ No newline at end of file diff --git a/Program/Common/Model/JwtSettings.cs b/Program/Common/Model/JwtSettings.cs new file mode 100644 index 0000000..5836c3e --- /dev/null +++ b/Program/Common/Model/JwtSettings.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; + +namespace AcaMate.Common.Models; + +public class JwtSettings +{ + public string SecretKey { get; set; } + public string Issuer { get; set; } + public string Audience { get; set; } + public int ExpiryMinutes { get; set; } + public int ClockSkewMinutes { get; set; } + public int RefreshTokenExpiryDays { get; set; } +} + + +[Table("refresh_token")] +public class RefreshToken +{ + [Key] + [Required(ErrorMessage = "필수 항목 누락")] + public string uid { get; set; } + public string refresh_token { get; set; } + public DateTime create_Date { get; set; } + public DateTime expire_date { get; set; } + + // 이건 로그아웃시에 폐기 시킬예정이니 그떄 변경하는걸로 합시다. + public DateTime? revoke_Date { get; set; } +} + +public class ValidateToken +{ + public string token { get; set; } + public string refresh { get; set; } + public string uid { get; set; } +} +/* +""" +토큰 동작 관련 +다시 물어보자 토큰 로직 관련해서 일단은 로그인을 예로 들면 +1. 로그인을 진행한다. +2. 회원이 DB에 정상적으로 존재한다. +3. 엑세스 토큰과 리프레시 토큰을 생성한다. +4. 엑세스 토큰은 클라이언트로 리프래시 토큰은 서버에 저장하고 클라이언트로도 보낸다. +5. (상황1) 시간이 경과해 엑세스 토큰의 시간이 경과했다. +6. (상황2) 회원 정보에 대한 접근이 필요한 동작을 수행한다. +7. 엑세스 토큰과 리프레시 토큰을 서버로 전송한다. +8. 엑세스 토큰이 만료가 되었음이 확인이 되면 리프레시 토큰으로 새 엑세스를 만들기 위해 리프레시 토큰을 확인한다. +9. 리프레시 토큰이 만료가 되지 않았다면 리프레시 토큰을 토대로 엑세스 토큰을 생성한다. +10. 생성된 엑세스 토큰을 가지고 상황2의 동작을 수행하고 엑세스 토큰을 반환한다. +""" +*/ \ No newline at end of file diff --git a/Program/Common/Model/Status.cs b/Program/Common/Model/Status.cs new file mode 100644 index 0000000..593d759 --- /dev/null +++ b/Program/Common/Model/Status.cs @@ -0,0 +1,78 @@ +using System.Text.Json; + +namespace AcaMate.Common.Models; + +public class APIResponseStatus +{ + public Status status { get; set; } + public T? data { get; set; } + + public string JsonToString() + { + return JsonSerializer.Serialize(this); + } +} + +public class Status +{ + public string code { get; set; } + public string message { get; set; } + +} + +public static class DefaultResponse +{ + // private static readonly Lazy _instance = new Lazy(); + // public static ErrorResponse Instace => _instance.Value; + + // private ErrorResponse() + // { + // // 외부 초기화 방지 + // } + + public static APIResponseStatus Success = new APIResponseStatus + { + status = new Status() + { + code = "000", + message = "정상" + } + }; + + public static APIResponseStatus InvalidInputError = new APIResponseStatus + { + status = new Status() + { + code = "001", + message = "입력 값이 유효하지 않습니다." + } + }; + + public static APIResponseStatus NotFoundError = new APIResponseStatus + { + status = new Status() + { + code = "002", + message = "알맞은 값을 찾을 수 없습니다." + } + }; + + public static APIResponseStatus InternalSeverError = new APIResponseStatus + { + status = new Status + { + code = "003", + message = "통신에 오류가 발생하였습니다." + } + }; + + + public static APIResponseStatus UnknownError = new APIResponseStatus + { + status = new Status() + { + code = "999", + message = "알 수 없는 오류가 발생하였습니다.." + } + }; +} diff --git a/Program/V1/Controllers/AppController.cs b/Program/V1/Controllers/AppController.cs new file mode 100644 index 0000000..f202268 --- /dev/null +++ b/Program/V1/Controllers/AppController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; +using AcaMate.Common.Data; +using AcaMate.Common.Models; +using Microsoft.IdentityModel.Tokens; +using Version = AcaMate.V1.Models.Version; + +namespace AcaMate.V1.Controllers; + +[ApiController] +[Route("/api/v1/in/app")] +[ApiExplorerSettings(GroupName = "공통")] +public class AppController : ControllerBase +{ + private readonly AppDbContext _dbContext; + + public AppController(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet("version")] + [CustomOperation("앱 버전 확인","앱 버전을 확인해서 업데이트 여부 판단", "시스템")] + public IActionResult GetVersionData(string type) + { + if (string.IsNullOrEmpty(type)) + { + return BadRequest(DefaultResponse.InvalidInputError); + } + + try + { + var version = _dbContext.Version.FirstOrDefault(v => v.os_type == (type == "I" ? "VO01" : "VO02")); + + if (version == null) + { + return NotFound(DefaultResponse.NotFoundError); + } + + var response = new APIResponseStatus + { + status = new Status() + { + code = "000", + message = "정상" + }, + data = new Version() + { + os_type = (version.os_type == "VO01" ? "I" : (version.os_type == "VO02" ? "A" : "W")), + final_ver = version.final_ver, + force_ver = version.force_ver, + dev_ver = version.dev_ver, + choice_update_yn = version.choice_update_yn + } + }; + + string jsonString = JsonSerializer.Serialize(response); + + // return Ok(jsonString); + return Ok(response.JsonToString()); + } + catch (Exception ex) + { + Console.WriteLine($"{ex.Message}\n{ex.StackTrace}"); + return StatusCode(500, DefaultResponse.UnknownError); + } + } +} \ No newline at end of file diff --git a/Program/V1/Controllers/ErrorController.cs b/Program/V1/Controllers/ErrorController.cs new file mode 100644 index 0000000..c1efbed --- /dev/null +++ b/Program/V1/Controllers/ErrorController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace AcaMate.V1.Controllers; + + +[ApiController] +[Route("/api/error")] +public class ErrorController: ControllerBase +{ + [HttpGet] + public IActionResult HandleError() + { + return Problem("오류가 발생하였습니다. 잠시후 다시 시도해주세요."); + } +} \ No newline at end of file diff --git a/Program/V1/Controllers/MemberController.cs b/Program/V1/Controllers/MemberController.cs new file mode 100644 index 0000000..bf8d442 --- /dev/null +++ b/Program/V1/Controllers/MemberController.cs @@ -0,0 +1,43 @@ + +using System.Text.Json; +using AcaMate.Common.Data; +using AcaMate.Common.Models; +using AcaMate.V1.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace AcaMate.V1.Controllers; + +[ApiController] +[Route("/api/v1/in/member")] +[ApiExplorerSettings(GroupName = "사업자 정보")] +public class MemberController: ControllerBase +{ + private readonly AppDbContext _dbContext; + + public MemberController(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet("business")] + public IActionResult GetBusinessData() + { + // return Ok("GOOD"); + return Ok("DB 참조"); + } + + + + + + + // -- -- -- -- -- -- -- -- -- -- -- -- // + + [HttpGet("/api/v1/out/member/business")] + public IActionResult SearchBusinessNo() + { + return Ok("외부 참조"); + } + +} \ No newline at end of file diff --git a/Program/V1/Controllers/PushController.cs b/Program/V1/Controllers/PushController.cs new file mode 100644 index 0000000..7cc0d10 --- /dev/null +++ b/Program/V1/Controllers/PushController.cs @@ -0,0 +1,105 @@ +using AcaMate.Common.Models; +using AcaMate.V1.Models; +using Microsoft.AspNetCore.Mvc; + +using AcaMate.V1.Services; + + + +namespace AcaMate.V1.Controllers; + +[ApiController] +[Route("/api/v1/in/push")] +[ApiExplorerSettings(GroupName = "공통")] +public class PushController : ControllerBase +{ + + private readonly IWebHostEnvironment _env; + public PushController(IWebHostEnvironment env) + { + _env = env; + } + + [HttpGet()] + [CustomOperation("푸시 확인", "저장된 양식을 확인 할 수 있다..", "푸시")] + public IActionResult GetPushData() + { + return Ok("SEND"); + } + + + /// + /// Sends a push notification to the specified device token with the provided payload. + /// + /// The device token to send the notification to. + /// The payload of the push notification. + /// An IActionResult indicating the result of the operation. + /// Push notification sent successfully. + /// Invalid input parameters. + /// Internal server error occurred. + /// Service unavailable. + [HttpPost("send")] + [CustomOperation("푸시전송", "저장된 양식으로, 사용자에게 푸시를 전송한다.", "푸시")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(APIResponseStatus))] + public async Task SendPush(string deviceToken, [FromBody] Payload? payload) + { + + if (string.IsNullOrWhiteSpace(deviceToken)) + { + var inputError = DefaultResponse.InvalidInputError; + inputError.status.message = "Deviece Toekn 오류"; + return StatusCode(500,inputError); + } + + + if (payload == null) + { + var inputError = DefaultResponse.InvalidInputError; + inputError.status.message = "payload 입력 오류"; + return StatusCode(500,inputError); + } + + string uri = ""; + string p12Path = ""; + string p12PWPath = "/src/private/appleKeys.json"; + // string p12PWPath = "private/appleKeys.json"; + string apnsTopic = "me.myds.ipstein.acamate.AcaMate"; + + + if (_env.IsDevelopment()) + { + uri = "https://api.sandbox.push.apple.com/"; + p12Path = "/src/private/AM_Push_Sandbox.p12"; + // p12Path = "private/AM_Push_Sandbox.p12"; + } + else + { + uri = "https://api.push.apple.com/"; + p12Path = "/src/private/AM_Push.p12"; + // p12Path = "private/AM_Push.p12"; + } + + // ApnsPushService 인스턴스 생성 + var pushService = new ApnsPushService(uri, p12Path, p12PWPath, apnsTopic); + + // 푸시 알림 전송 + var result = await pushService.SendPushNotificationAsync(deviceToken, payload); + if (result.Success) + { + return Ok(DefaultResponse.Success.JsonToString()); + } + else + { + + var apnsError = DefaultResponse.InternalSeverError; + apnsError.status.message = $"{result.Message}"; + return result.Code switch + { + "002" => StatusCode(002, apnsError), + "003" => StatusCode(003, apnsError), + "999" => StatusCode(999, apnsError) + }; + } + + } +} \ No newline at end of file diff --git a/Program/V1/Controllers/UserController.cs b/Program/V1/Controllers/UserController.cs new file mode 100644 index 0000000..00b165d --- /dev/null +++ b/Program/V1/Controllers/UserController.cs @@ -0,0 +1,311 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore.Query; +using MySqlConnector; +using System.Linq; + +using AcaMate.Common.Data; +using AcaMate.Common.Token; +using AcaMate.Common.Models; + +using AcaMate.V1.Models; +using AcaMate.V1.Services; + +namespace AcaMate.V1.Controllers; + +[ApiController] +[Route("/api/v1/in/user")] +[ApiExplorerSettings(GroupName = "사용자")] +public class UserController : ControllerBase +{ + private readonly AppDbContext _dbContext; + private readonly ILogger _logger; + private readonly JwtTokenService _jwtTokenService; + private readonly IRepositoryService _repositoryService; + + public UserController(AppDbContext dbContext, ILogger logger, JwtTokenService jwtTokenService, IRepositoryService repositoryService) + { + _dbContext = dbContext; + _logger = logger; + _jwtTokenService = jwtTokenService; + _repositoryService = repositoryService; + } + + [HttpGet] + [CustomOperation("회원 정보 조회", "회원 정보 조회", "사용자")] + public IActionResult GetUserData(string uid) + { + if (string.IsNullOrEmpty(uid)) return BadRequest(DefaultResponse.InvalidInputError); + + try + { + var user = _dbContext.User + .Where(u => u.uid == uid) + .Select(u => new User + { + uid = u.uid, + name = u.name, + auto_login_yn = u.auto_login_yn, + birth = u.birth, + device_id = u.device_id, + login_date = u.login_date, + type = u.type + }) + .FirstOrDefault(); + + var response = new APIResponseStatus + { + status = new Status + { + code = "000", + message = "정상" + }, + data = user + }; + return Ok(response.JsonToString()); + } + catch (Exception ex) + { + return StatusCode(500, DefaultResponse.UnknownError); + } + } + + [HttpGet("login")] + [CustomOperation("SNS 로그인", "로그인 후 회원이 있는지 확인", "사용자")] + public async Task Login(string acctype, string sns_id) + { + // API 동작 파라미터 입력 값 확인 + if (string.IsNullOrEmpty(acctype) && string.IsNullOrEmpty(sns_id)) + return BadRequest(DefaultResponse.InvalidInputError); + + try + { + var login = await _dbContext.Login + .FirstOrDefaultAsync(l => l.sns_type == acctype && l.sns_id == sns_id); + + if (login != null) + { + // 로그인 정보가 존재 하는 상황 + var uid = login.uid; + + var user = await _dbContext.User + .FirstOrDefaultAsync(u => u.uid == uid); + + if (user != null) + { + // 정상적으로 User 테이블에도 있는것이 확인 됨 + user.login_date = DateTime.Now; + await _dbContext.SaveChangesAsync(); + + // 토큰 생성은 로그인이 이제 되고 나서 한다. + var accessToken = _jwtTokenService.GenerateJwtToken(uid);//, "Normal"); + var refreshToken = _jwtTokenService.GenerateRefreshToken(uid); + _logger.LogInformation($"{uid}: {accessToken}, {refreshToken}"); + + await _repositoryService.SaveData(refreshToken, rt => rt.uid); + + + var response = new APIResponseStatus + { + status = new Status + { + code = "000", + message = "정상" + }, + data = new + { + token = accessToken, + refresh = refreshToken.refresh_token + } + }; + return Ok(response.JsonToString()); + } + } + // case 1: Login 테이블에 값이 없다 == 로그인이 처음 + // case 2: User 테이블에 값이 없다 == 이건 문제가 있는 상황 -> 해결은 회원가입 재 진행 시도 + // Login에는 있는데 User 테이블에 없다? 말이 안되긴 하는데... + return Ok(new APIResponseStatus + { + status = new Status + { + code = "010", + message = "로그인 정보 없음 > 회원 가입 진행" + }, + data = new + { + token = "", + refresh = "" + // bidList = new string[] { } + } + }.JsonToString()); + } + catch (Exception ex) + { + _logger.LogInformation($"[로그인] 에러 발생 : {ex}"); + return StatusCode(500, DefaultResponse.UnknownError); + } + } + + [HttpGet("academy")] + [CustomOperation("학원 리스트 확인", "사용자가 등록된 학원 리스트 확인", "사용자")] + public async Task ReadAcademyInfo(string token, string refresh) + { + _logger.LogInformation($"토큰 : {token}, {refresh}"); + + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(refresh)) + { + var error = DefaultResponse.InvalidInputError; + return Ok(error); + } + + try + { + var validateToken = await _repositoryService.ValidateToken(token, refresh); + var uid = validateToken.uid; + + var userAcademy = await _dbContext.UserAcademy + .Where(ua => ua.uid == uid) + .Select(ua => ua.bid) + .ToListAsync(); + + + var academies = await _dbContext.Academy + .Where(a => userAcademy.Contains(a.bid)) + .Select(a => new AcademyName + { + bid = a.bid, + name = a.business_name + }) + .ToListAsync(); + + var response = new APIResponseStatus> + { + status = new Status + { + code = "000", + message = "정상" + }, + data = academies + }; + + + return Ok(response); + } + catch (SecurityTokenException tokenEx) + { + _logger.LogInformation($"[로그인][오류] 토큰 검증 : {tokenEx}"); + return StatusCode(500, DefaultResponse.InvalidInputError); + } + catch (Exception ex) + { + _logger.LogInformation($"[로그인][오류] 에러 발생 : {ex}"); + return StatusCode(500, DefaultResponse.UnknownError); + } + + } + + [HttpPost("register")] + [CustomOperation("회원 가입", "사용자 회원 가입", "사용자")] + public async Task UserRegister([FromBody] UserAll request) + { + if (!ModelState.IsValid) return BadRequest(DefaultResponse.InvalidInputError); + + var atIndext = request.email.IndexOf('@'); + var localPart_email = request.email.Substring(0, atIndext); + var uid = $"AM{localPart_email}{DateTime.Now:yyyyMMdd}"; + + var user = new User + { + uid = uid, + name = request.name, + birth = request.birth, + type = request.type, + device_id = request.device_id, + auto_login_yn = request.auto_login_yn, + login_date = request.login_date + }; + var login = new Login + { + uid = uid, + sns_id = request.sns_id, + sns_type = request.sns_type + }; + + var permission = new Permission + { + uid = uid, + location_yn = request.location_yn, + camera_yn = request.camera_yn, + photo_yn = request.photo_yn, + push_yn = request.push_yn, + market_app_yn = request.market_app_yn, + market_sms_yn = request.market_sms_yn, + market_email_yn = request.market_email_yn + }; + + var contact = new Contact + { + uid = uid, + email = request.email, + phone = request.phone, + address = request.address + }; + + + if (await _repositoryService.SaveData(user, u => u.uid)) + { + await _repositoryService.SaveData(login, l => l.sns_id); + await _repositoryService.SaveData(permission, p => p.uid); + await _repositoryService.SaveData(contact, c => c.uid); + } + + // TO-DO: jwt 토큰 만들어서 여기서 보내는 작업을 해야 함 + var token = _jwtTokenService.GenerateJwtToken(uid);//, "admin"); + var refreshToken = _jwtTokenService.GenerateRefreshToken(uid); + await _repositoryService.SaveData(refreshToken, rt => rt.uid); + + var result = new APIResponseStatus() + { + status = new Status() + { + code = "000", + message = "정상" + }, + data = new + { + accessToken = token, + refreshToken = refreshToken.refresh_token + } + }; + + return Ok(result.JsonToString()); + } + + [HttpGet("logout")] + [CustomOperation("로그아웃", "사용자 로그아웃", "사용자")] + public async Task LogOut(string token, string refresh) //([FromBody] UserAll request) + { + /* */ + // var value = await ValidateToken(token, refresh); + // _logger.LogInformation(value.uid); + // _logger.LogInformation(value.refresh); + // _logger.LogInformation(value.token); + /* */ + + + return Ok("로그아웃"); + } + + + + + + + + +} \ No newline at end of file diff --git a/Program/V1/Models/APIResult.cs b/Program/V1/Models/APIResult.cs new file mode 100644 index 0000000..2c00628 --- /dev/null +++ b/Program/V1/Models/APIResult.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace AcaMate.V1.Models; + +public class APIResult +{ + public bool Success { get; set; } + public string Code { get; set; } + public string Message { get; set; } + + public string JsonToString() + { + return JsonSerializer.Serialize(this); + } +} \ No newline at end of file diff --git a/Program/V1/Models/Academy.cs b/Program/V1/Models/Academy.cs new file mode 100644 index 0000000..8f8cc02 --- /dev/null +++ b/Program/V1/Models/Academy.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace AcaMate.V1.Models; + +[Table("academy")] +public class Academy +{ + [Key] + public string bid { get; set; } + public string business_name { get; set; } + public string business_owner { get; set; } + public string business_number { get; set; } + public DateTime business_date { get; set; } + public string business_address { get; set; } + public string business_contact { get; set; } + public string uid { get; set; } +} + +// -- -- -- -- -- DB 테이블 -- -- -- -- -- // + +public class AcademyName +{ + public string bid { get; set; } + public string name { get; set; } +} + +public class RequestAcademy +{ + public List bids { get; set; } +} \ No newline at end of file diff --git a/Program/V1/Models/PushPayload.cs b/Program/V1/Models/PushPayload.cs new file mode 100644 index 0000000..fd7eae8 --- /dev/null +++ b/Program/V1/Models/PushPayload.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography.X509Certificates; + +namespace AcaMate.V1.Models; + +public class Payload +{ + public Aps aps { get; set; } + // public string customKey { get; set; } 이런식으로 추가도 가능 + + public string ToJson() + { + return System.Text.Json.JsonSerializer.Serialize(this); + } +} + +public class Aps +{ + public Aps() + { + sound = "default"; + content_available = 1; + } + [Required(ErrorMessage = "필수 입력 누락 (alert)")] + public Alert alert { get; set; } + + [Required(ErrorMessage = "필수 입력 누락 (badge")] + public int badge { get; set; } // 앱 아이콘 표시 배지 숫자 설정 + public string sound { get; set; } // 사운드 파일 이름 default = "default" + public int content_available { get; set; } // 백그라운드 알림 활성화: 필수 (1) + public string? category { get; set; } // 알림에 대한 특정 액션을 정의 +} + +public class Alert +{ + [Required(ErrorMessage = "필수 입력 누락 (title")] + public string title { get; set; } // 제목 + [Required(ErrorMessage = "필수 입력 누락 (body)")] + public string body { get; set; } // 내용 + public string? subtitle { get; set; } // 부제목 (선택) +} \ No newline at end of file diff --git a/Program/V1/Models/User.cs b/Program/V1/Models/User.cs new file mode 100644 index 0000000..2eab154 --- /dev/null +++ b/Program/V1/Models/User.cs @@ -0,0 +1,150 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Runtime.InteropServices.JavaScript; + +namespace AcaMate.V1.Models; + +[Table("login")] +public class Login +{ + [Key] + [Required(ErrorMessage = "필수 항목 누락")] + [MaxLength(100)] + public string sns_id {get; set;} + + [Required(ErrorMessage = "필수 항목 누락")] + [MaxLength(70)] + public string uid {get; set;} + + [Required(ErrorMessage = "필수 항목 누락")] + [MaxLength(4)] + public string sns_type {get; set;} +} + +[Table("user_academy")] +public class User_Academy +{ + [Key] + [Required(ErrorMessage = "필수 항목 누락")] + public string uid { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string bid { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public DateTime register_date { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public bool status { get; set; } +} + +[Table("user")] +public class User +{ + [Key] + [Required(ErrorMessage = "필수 항목 누락")] + public string uid { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string name { get; set; } + public DateTime? birth { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string type { get; set; } + + public string? device_id { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public bool auto_login_yn { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public DateTime login_date { get; set; } +} + + +[Table("permission")] +public class Permission +{ + [Key] + + [Required(ErrorMessage = "필수 항목 누락")] + public string uid { get; set; } + + public bool location_yn {get; set;} + public bool camera_yn {get; set;} + public bool photo_yn {get; set;} + public bool push_yn {get; set;} + public bool market_app_yn {get; set;} + public bool market_sms_yn {get; set;} + public bool market_email_yn {get; set;} +} + + +[Table("location")] +public class Location +{ + [Key] + + [Required(ErrorMessage = "필수 항목 누락")] + public string uid { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string lat { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string lng { get; set; } +} + +[Table("contact")] +public class Contact +{ + [Key] + + [Required(ErrorMessage = "필수 항목 누락")] + public string uid { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string email { get; set; } + public string? phone { get; set; } + public string? address { get; set; } +} + +// -- -- -- -- -- DB 테이블 -- -- -- -- -- // + +public class UserAll +{ + // [Required(ErrorMessage = "필수 항목 누락")] + // public string uid { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string name { get; set; } + + public DateTime? birth { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string type { get; set; } + + public string? device_id { get; set; } + + public bool auto_login_yn { get; set; } + public DateTime login_date { get; set; } + + [Required(ErrorMessage = "필수 항목 누락")] + public string email { get; set; } + public string? phone { get; set; } + public string? address { get; set; } + + public bool location_yn {get; set;} + public bool camera_yn {get; set;} + public bool photo_yn {get; set;} + public bool push_yn {get; set;} + public bool market_app_yn {get; set;} + public bool market_sms_yn {get; set;} + public bool market_email_yn {get; set;} + + [Required(ErrorMessage = "필수 항목 누락")] + public string sns_id {get; set;} + + [Required(ErrorMessage = "필수 항목 누락")] + public string sns_type {get; set;} +} \ No newline at end of file diff --git a/Program/V1/Models/Version.cs b/Program/V1/Models/Version.cs new file mode 100644 index 0000000..ef25105 --- /dev/null +++ b/Program/V1/Models/Version.cs @@ -0,0 +1,21 @@ + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace AcaMate.V1.Models; + +[Table("version")] +public class Version +{ + [Key] + [MaxLength(4)] + public string os_type { get; set; } + [MaxLength(8)] + public string final_ver { get; set; } + [MaxLength(8)] + public string dev_ver { get; set; } + [MaxLength(8)] + public string force_ver { get; set; } + public bool choice_update_yn { get; set; } + +} \ No newline at end of file diff --git a/Program/V1/Repositories/UserRepository.cs b/Program/V1/Repositories/UserRepository.cs new file mode 100644 index 0000000..b47a88c --- /dev/null +++ b/Program/V1/Repositories/UserRepository.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using AcaMate.Common.Data; +using AcaMate.Common.Models; +using AcaMate.V1.Models; + +namespace AcaMate.V1.Repositories; + +public class UserRepository +{ + + private readonly AppDbContext _context; + + + public UserRepository(AppDbContext context) { + _context = context; + + } + + /* + public async Task> GetUserAcademyInfoBySnsIdAsync(string snsId) + { + + }*/ +} \ No newline at end of file diff --git a/Program/V1/Services/PushService.cs b/Program/V1/Services/PushService.cs new file mode 100644 index 0000000..0e2fab0 --- /dev/null +++ b/Program/V1/Services/PushService.cs @@ -0,0 +1,126 @@ +using System; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using AcaMate.V1.Models; +using Version = System.Version; + +public class ApnsPushService +{ + private readonly string _uri; + private readonly string _p12Path; + private readonly string _p12PWPath; + private readonly string _apnsTopic; + + + public ApnsPushService(string uri,string p12Path, string p12PWPath, string apnsTopic) + { + _uri = uri ?? throw new ArgumentNullException(nameof(uri)); + _p12Path = p12Path ?? throw new ArgumentNullException(nameof(p12Path)); + _p12PWPath = p12PWPath ?? throw new ArgumentNullException(nameof(p12PWPath)); + _apnsTopic = apnsTopic ?? throw new ArgumentNullException(nameof(apnsTopic)); + } + + public async Task SendPushNotificationAsync(string deviceToken, Payload payload) + { + + // 존재 안하면 예외 던져 버리는거 + if (!File.Exists(_p12PWPath)) + { + Console.WriteLine($"File not found: {_p12PWPath}"); + return new APIResult + { + Success = false , + Code = "003", + Message = "서버 오류 : p12 PW 파일 확인 필요" + }; + } + + var jsonPayload = JsonSerializer.Serialize(payload); + + var keys = JsonSerializer.Deserialize>(File.ReadAllText(_p12PWPath)); + var p12Password = keys["Password"]; + + try + { + var certificate = new X509Certificate2(_p12Path, p12Password); + Console.WriteLine($"Certificate Subject: {certificate.Subject}"); + Console.WriteLine($"Certificate Issuer: {certificate.Issuer}"); + + var handler = new HttpClientHandler(); + handler.ClientCertificates.Add(certificate); + handler.SslProtocols = System.Security.Authentication.SslProtocols.Tls12; + + using var client = new HttpClient(handler) + { + BaseAddress = new Uri(_uri), + Timeout = TimeSpan.FromSeconds(60) + }; + + var request = new HttpRequestMessage(HttpMethod.Post, $"/3/device/{deviceToken}") + { + Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"), + Version = new Version(2, 0) // HTTP/2 사용 + }; + + // 필수 헤더 추가 + request.Headers.Add("apns-topic", _apnsTopic); + request.Headers.Add("apns-push-type", "alert"); + + Console.WriteLine($"Send -> Payload: {jsonPayload}"); + + var response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + return new APIResult + { + Success = true + }; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + return new APIResult + { + Success = false, + Code = "003", + Message = $"APN 통신 실패 = {response.StatusCode} : {errorContent}" + }; + } + } + catch (HttpRequestException httpEx) + { + Console.WriteLine($"HttpRequestException: {httpEx.Message}"); + return new APIResult + { + Success = false, + Code = "003", + Message = $"통신 실패 : {httpEx.Message}" + }; + } + + catch (CryptographicException ex) + { + return new APIResult + { + Success = false, + Code = "999", + Message = $"오류 발생 : {ex.Message}" + }; + } + + catch (Exception ex) + { + return new APIResult + { + Success = false, + Code = "999", + Message = $"오류 발생 : {ex.Message}" + }; + } + } +} \ No newline at end of file diff --git a/Program/V1/Services/RepositoryService.cs b/Program/V1/Services/RepositoryService.cs new file mode 100644 index 0000000..f53fb60 --- /dev/null +++ b/Program/V1/Services/RepositoryService.cs @@ -0,0 +1,136 @@ +using AcaMate.Common.Data; +using AcaMate.Common.Token; +using AcaMate.Common.Models; + + +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; + +namespace AcaMate.V1.Services; + +public interface IRepositoryService +{ + Task SaveData(T entity, Expression> key) where T : class; + Task ValidateToken(string token, string refresh); +} + +public class RepositoryService: IRepositoryService +{ + private readonly AppDbContext _dbContext; + private readonly ILogger _logger; + private readonly JwtTokenService _jwtTokenService; + + public RepositoryService(AppDbContext dbContext, ILogger logger, JwtTokenService jwtTokenService) + { + _dbContext = dbContext; + _logger = logger; + _jwtTokenService = jwtTokenService; + } + + public async Task SaveData (T entity, Expression> key) where T : class + { + try + { + var value = key.Compile()(entity); + + // x 라 함은 Expression 으로 생성되는 트리에서 T 타입으의 매개변수를 지칭함 + var parameter = Expression.Parameter(typeof(T), "x"); + + var invokedExpr = Expression.Invoke(key, parameter); + var constantExpr = Expression.Constant(value, key.Body.Type); + var equalsExpr = Expression.Equal(invokedExpr, constantExpr); + + var predicate = Expression.Lambda>(equalsExpr, parameter); + + var dbSet = _dbContext.Set(); + + var entityData = await dbSet.FirstOrDefaultAsync(predicate); + + if (entityData != null) + { + _logger.LogInformation($"[{typeof(T)}] 해당 PK 존재 [{value}]: 계속"); + var entry = _dbContext.Entry(entityData); + entry.CurrentValues.SetValues(entity); + if (entry.Properties.Any(p => p.IsModified)) + { + _logger.LogInformation($"[{typeof(T)}] 변경사항 존재: 계속"); + } + else + { + _logger.LogInformation($"[{typeof(T)}] 변경사항 없음: 종료"); + return true; + } + } + else + { + _logger.LogInformation($"[{typeof(T)}] 처음등록"); + dbSet.Add(entity); + } + + await _dbContext.SaveChangesAsync(); + _logger.LogInformation($"[{typeof(T)}] DB 저장 완료: 종료"); + return true; + } + catch (Exception ex) + { + _logger.LogInformation($"[{typeof(T)}] 알 수 없는 오류: 종료 {ex}"); + return false; + } + } + + //토큰 태울때는 인코딩 된 걸로 태워야지 원본꺼 태우면 데이터에 손상옵니다. + public async Task ValidateToken(string token, string refresh) + { + var principalToken = _jwtTokenService.ValidateToken(token); + if (principalToken != null) + { + var uid = principalToken.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty; + _logger.LogInformation($"토큰 변환 - {uid}"); + return new ValidateToken + { + token = token, + refresh = refresh, + uid = uid + }; + } + else + { + _logger.LogInformation("엑세스 토큰 만료"); + var refreshToken = await _dbContext.RefreshTokens + .FirstOrDefaultAsync(t => t.refresh_token == refresh); + if (refreshToken == null) + { + throw new SecurityTokenException("리프레시 토큰도 잘못되었음"); + } + var uid = refreshToken.uid; + + if (refreshToken.expire_date > DateTime.Now) + { + _logger.LogInformation($"리프레시 : {uid}"); + var access = _jwtTokenService.GenerateJwtToken(uid); + return new ValidateToken + { + token = access, + refresh = refreshToken.refresh_token, + uid = uid + }; + } + else + { + refreshToken = _jwtTokenService.GenerateRefreshToken(uid); + _logger.LogInformation("리프레시 토큰 만료"); + await SaveData(refreshToken, rt => rt.uid); + return new ValidateToken + { + token = token, + refresh = refreshToken.refresh_token, + uid = uid + }; + } + } + } + +} \ No newline at end of file diff --git a/Program/V1/Services/UserService.cs b/Program/V1/Services/UserService.cs new file mode 100644 index 0000000..5d078bb --- /dev/null +++ b/Program/V1/Services/UserService.cs @@ -0,0 +1,7 @@ +namespace AcaMate.V1.Services; + +public class UserService +{ + // priva + +} \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..bce0946 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:57683", + "sslPort": 44307 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5144", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7086;http://localhost:5144", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f977ff6 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# API Server + +## Development Environment +### Skill +- .NET Web API +### IDE +- JetBrains Rider + +### 추가 패키지 +| No. | Name | Version | Description | +|:---:|:-------------------------------------------:|:-------:|:---------------------------| +| 1 | Microsoft.AspNetCore.OpenApi | 8.0.10 | OpenAPI 를 지원하기 위해 사용 | +| 2 | Microsoft.EntityFrameworkCore | 8.0.10 | 데이터베이스 작업을 간편하게 수행하기 위해 사용 | +| 3 | Pomelo.EntityFrameworkCore.MySql | 8.0.2 | MariaDB 연결 | +| 4 |Microsoft.AspNetCore.Authentication.JwtBearer| 8.0.10 | | + | 5 |Dapper|2.1.35|SQL 직접 작성| diff --git a/SwaggerConfigure.cs b/SwaggerConfigure.cs new file mode 100644 index 0000000..6526728 --- /dev/null +++ b/SwaggerConfigure.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.Annotations; +using Swashbuckle.AspNetCore.SwaggerGen; + + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class CustomOperationAttribute : Attribute +{ + public string Summary { get; } + public string Description { get; } + public string[] Tags { get; } + + public CustomOperationAttribute(string summary, string description, params string[] tags) + { + Summary = summary; + Description = description; + Tags = tags; + } +} + + + +public class CustomSwaggerOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var customSwaggerAttribute = context.MethodInfo.GetCustomAttributes(typeof(CustomOperationAttribute), false) + .FirstOrDefault() as CustomOperationAttribute; + + if (customSwaggerAttribute != null) + { + operation.Summary = customSwaggerAttribute.Summary; + operation.Description = customSwaggerAttribute.Description; + operation.Tags = customSwaggerAttribute.Tags + .Select(tag => new OpenApiTag { Name = tag }) + .ToList(); + } + } +} + +public static class SwaggerConfigure +{ + private static OpenApiInfo DocName(string title, string version) + { + return new OpenApiInfo + { + Title = title, + Version = version + }; + } + public static void AddCustomSwagger(this IServiceCollection services) + { + services.AddSwaggerGen(options => + { + options.EnableAnnotations(); + options.OperationFilter(); + options.SwaggerDoc("전체", DocName("AcaMate 전체 API","1.0.0")); + options.SwaggerDoc("공통",DocName("공통 API", "1.0.0")); + options.SwaggerDoc("사업자 정보", DocName("사업자 정보 API", "1.0.0")); + options.SwaggerDoc("사용자", DocName("사용자 API", "1.0.0")); + + options.DocInclusionPredicate((docName, apiDesc) => + { + if (docName == "전체") return true; // 전체 문서에 모든 API 포함 + if (docName == "공통" && apiDesc.GroupName == "공통") return true; + if (docName == "사업자 정보" && apiDesc.GroupName == "사업자 정보") return true; + if (docName == "사용자" && apiDesc.GroupName == "사용자") return true; + return false; + }); + + options.TagActionsBy(apiDesc => new[] { apiDesc.GroupName ?? "기타" }); + + + // options.TagActionsBy(apiDesc => apiDesc.ActionDescriptor.EndpointMetadata + // .OfType() + // .FirstOrDefault()?.Tags ?? new[] { "기타" }); + }); + } + + public static void UseCustomSwaggerUI(this IApplicationBuilder app) + { + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/전체/swagger.json", "전체 API"); + options.SwaggerEndpoint("/swagger/공통/swagger.json", "공통 API"); + options.SwaggerEndpoint("/swagger/사용자/swagger.json", "사용자 API"); + options.SwaggerEndpoint("/swagger/사업자 정보/swagger.json", "사업자 정보 API"); + }); + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..d66442c --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file