Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb748a66a3 | |||
|
|
2adb34acab | ||
| b2485569be | |||
|
|
44f6defa84 | ||
| 8ccde89dc0 | |||
|
|
420a036c36 | ||
| c97ae32080 | |||
|
|
b8d87377b9 | ||
| 6c3a384a99 | |||
|
|
71cd7a5e98 | ||
| 165328b7df | |||
|
|
1ca4980293 | ||
| 432fde0baf | |||
|
|
cb4cf01c4f | ||
| e0af7cd604 | |||
| 42bf814af1 | |||
|
|
9dcdd56b2f | ||
| b02910a213 | |||
|
|
748aa3e3b8 | ||
| 9164d9156b | |||
|
|
3ea873e438 | ||
|
|
ecddbe1c26 | ||
| acfa988c43 | |||
|
|
30c40d449d | ||
| 71172d738b | |||
|
|
71102e38ac | ||
| 49da5a91c8 | |||
|
|
f31964c92e | ||
| 335676a282 | |||
|
|
04dd5be046 | ||
| 7dcdb03796 | |||
|
|
c29a48163d | ||
| f474b916c4 | |||
|
|
4db27aaf8a | ||
| 0ee0da4fa4 | |||
|
|
7ffc152536 | ||
| 6b4f502bb8 | |||
|
|
c458cfe4e7 | ||
| ef00ea130d | |||
|
|
a2d563aa9d | ||
| 76873e7fbc | |||
|
|
48049bba9e | ||
| d98f8c89a4 | |||
|
|
afaeb6d116 | ||
| 016550e3b9 | |||
|
|
65eb9e785a | ||
| 5fc2221d5b | |||
|
|
3d8c57f690 | ||
| 9350066fb4 | |||
|
|
347c9aa4bf | ||
| f33971a1d0 | |||
|
|
ffde006e94 | ||
| b177557094 | |||
|
|
a3b2da5ffb | ||
| 15a2dd66e5 | |||
|
|
46a2105c13 | ||
| a08f0a958c | |||
|
|
0eacf25eb3 | ||
| d21fb7c883 | |||
|
|
011cb9b380 | ||
| b9b3fa2fc0 | |||
|
|
b373d59710 | ||
| fecd322763 | |||
|
|
351135549e | ||
| c20025e181 | |||
|
|
17caeb08e2 | ||
| 044ebc17d0 | |||
|
|
e3ed3d4267 | ||
| e50f3f186c | |||
|
|
d051ff3b97 | ||
| 06d2f6d023 | |||
|
|
4916488175 | ||
| a44f023027 | |||
|
|
4577d8c10d | ||
| 7c9939787e | |||
|
|
42aa04f58e | ||
| 09831ebcbf | |||
|
|
3acae06ed1 | ||
| b6008fb657 | |||
|
|
3cc99c0284 | ||
| 7155fb58dc | |||
|
|
859eabd83c | ||
| 512585e7e7 | |||
|
|
8224c7a17b | ||
| 10460b40c3 | |||
|
|
bf8f82e66c | ||
| 68fe6b91a5 | |||
|
|
f04eb44fff | ||
| a37e57f789 | |||
|
|
4bc08715fa | ||
| febd6f6da0 | |||
|
|
74e6bd83dc | ||
| 890feb9b4c | |||
|
|
a6d9f2a46f | ||
| b1cac9d08a | |||
|
|
8b1ae4dc02 | ||
| f972982b85 | |||
|
|
c63a61bf6a | ||
| bbcb770b2d | |||
|
|
1b6a87588c | ||
| fec1bf289f | |||
|
|
feca00e329 | ||
| ffdc343563 | |||
|
|
ca4f278b14 | ||
| 17b9f14372 | |||
|
|
87b48441cb | ||
| 9ab7d4786d | |||
|
|
d717603365 | ||
| b5de3ca2d1 | |||
|
|
2aa676d60f | ||
| 5890392121 | |||
|
|
a5b8bda162 | ||
| 519569ab72 | |||
|
|
3a973f56ce | ||
| 042e6e1dd6 | |||
|
|
d3bd4356a8 | ||
| ce266956c7 | |||
|
|
bcc40b4c01 | ||
| 86f978633e | |||
|
|
3e5aeacd5e | ||
| e0f7d422c3 | |||
| caa2148654 | |||
| 0911fc763a | |||
| d940948df0 | |||
| 4298171d61 | |||
| 830cbf2edc | |||
| dc487609b3 | |||
| 01c8f7602f | |||
| fc7ab51fa3 | |||
| f8eb938a9d | |||
| f70d8a8558 | |||
| 01cc3adea4 | |||
| 3fc3bb8144 | |||
| efd6615809 | |||
| 355d3269c0 | |||
|
|
b56170f10c | ||
| 975ed77d18 | |||
|
|
ef6d71a921 | ||
| 6f58633de9 | |||
|
|
ce7b8b3d35 | ||
| fc16884d25 | |||
|
|
fe1dcd0176 | ||
| 47dff6b2f0 | |||
|
|
73de5efd84 | ||
| b5d6c70b16 | |||
|
|
814d2082cb | ||
| a11750db93 | |||
|
|
f36f8f47a9 | ||
| 81c3eee1f3 | |||
|
|
4292f57ad1 | ||
| 639069972b | |||
|
|
3a5c2a5b5b | ||
| e2cf76fd11 | |||
|
|
7a250847f4 | ||
| 08aa74138f | |||
|
|
4f38e31710 | ||
| 1cae5c3754 | |||
|
|
0db4a8824d | ||
| b661fb5a08 | |||
|
|
658fa1d63d | ||
| 314df2e664 | |||
|
|
e355c8be62 | ||
| 2514f82ad1 | |||
|
|
e9bcd5358f | ||
| 7db6099cbe | |||
|
|
2037a409ef | ||
| 31df012976 | |||
|
|
57fdfdea0e | ||
| 14b76d0dbd | |||
|
|
7485e139cd | ||
| f07beda6c9 | |||
|
|
0d80b8c25f | ||
| bd4d6424c4 | |||
|
|
6475c0c753 | ||
| 0ead7a4bcb | |||
|
|
e24e0c2398 | ||
| 8e52802bbf | |||
|
|
ad1bf2e4e6 | ||
| f5a1afc6b3 | |||
|
|
f0d325fda9 | ||
| c8c9a44b0f | |||
|
|
1aab5032db | ||
| 59c833e7f7 | |||
|
|
aea0d358e8 | ||
| d821acb9a1 | |||
|
|
473fe525f3 | ||
| e288f28123 | |||
|
|
6879d5e1fd | ||
| cc10378efa | |||
|
|
65f2f914e7 | ||
| 808a42de14 | |||
|
|
e78f8e7c85 | ||
| 8770fee529 | |||
|
|
59f5dbf839 | ||
| 5d22fed32a | |||
|
|
5f25614e53 | ||
|
|
ccdfcbd62e | ||
|
|
e81b12fbea | ||
| e96bbff727 | |||
| f0761a15ca | |||
| 179e5897bf | |||
|
|
5d49a2ce49 | ||
|
|
f798b290ec | ||
| 94b0787bf8 | |||
|
|
16550dbff3 | ||
| b5b015255e | |||
|
|
e1bab0cce6 | ||
| de679f6f7e | |||
|
|
21dc6e608c | ||
| 9762052dd6 | |||
| c8a3d616c3 | |||
| e335f762bd | |||
| 94e0b92780 | |||
| 3b4b1873a3 | |||
| 4cb54e4c41 | |||
| 3d793c652b | |||
| cac56761f4 | |||
| b58662b520 | |||
| b6939c0fa9 | |||
| e0bf0adf70 | |||
| 9b9ca64b10 | |||
| 5eb3635719 | |||
| 336dcf8193 | |||
| f037977102 | |||
| b11c8dc918 | |||
| 0ccef1e10f | |||
|
|
9185afd5e9 | ||
| c49807c985 | |||
|
|
df8a8e2e5b | ||
| 4270e70f09 | |||
|
|
58b94c6298 | ||
| fb0da8669d | |||
|
|
27f33f809b | ||
| 0070ae58b9 | |||
|
|
cd8270c5c0 | ||
|
|
3125726e2c | ||
|
|
65b4207f94 | ||
|
|
9107726c3b | ||
| 3f439e4d4e | |||
|
|
a9c944a27d | ||
| 9bd39669f3 | |||
|
|
d6b15c3cd8 | ||
| f3714ff8fb | |||
|
|
2d30aaf212 | ||
| 24e1ccbfef | |||
|
|
787190f512 | ||
| 3293c38360 | |||
|
|
a9f6a436c4 | ||
| f380b348a9 | |||
|
|
4f0a4b9bf9 | ||
| 5d8e30494e | |||
|
|
da2001c79b | ||
| c387aa4465 | |||
|
|
8b6fd84b98 | ||
| 41c9667e5a | |||
|
|
cfce5ca8b8 |
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -57,3 +57,10 @@ Dockerfile
|
||||||
# 기타 캐시 파일
|
# 기타 캐시 파일
|
||||||
**/*.cache
|
**/*.cache
|
||||||
**/*.tmp
|
**/*.tmp
|
||||||
|
|
||||||
|
# 프로젝트 문서 및 AI 설정
|
||||||
|
Documents/
|
||||||
|
CLAUDE.md
|
||||||
|
TASKS.md
|
||||||
|
TODO.md
|
||||||
|
.mcp.json
|
||||||
|
|
|
||||||
165
SPMS.API/Controllers/AccountController.cs
Normal file
165
SPMS.API/Controllers/AccountController.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Account;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/account")]
|
||||||
|
[ApiExplorerSettings(GroupName = "account")]
|
||||||
|
[Authorize]
|
||||||
|
public class AccountController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
|
||||||
|
public AccountController(IAccountService accountService)
|
||||||
|
{
|
||||||
|
_accountService = accountService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("create")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 계정 생성",
|
||||||
|
Description = "Super Admin이 새로운 운영자(Manager/User) 계정을 생성합니다.")]
|
||||||
|
[SwaggerResponse(200, "생성 성공", typeof(ApiResponse<AccountResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(409, "이메일 중복")]
|
||||||
|
public async Task<IActionResult> CreateAsync([FromBody] CreateAccountRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _accountService.CreateAsync(request);
|
||||||
|
return Ok(ApiResponse<AccountResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 목록 조회",
|
||||||
|
Description = "Super Admin이 운영자(Manager/User) 목록을 조회합니다. Super Admin은 목록에 포함되지 않습니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<AccountListResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] AccountListRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _accountService.GetListAsync(request);
|
||||||
|
return Ok(ApiResponse<AccountListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{adminCode}")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 상세 조회",
|
||||||
|
Description = "Super Admin이 특정 운영자의 상세 정보를 조회합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<AccountResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> GetByAdminCodeAsync([FromRoute] string adminCode)
|
||||||
|
{
|
||||||
|
var result = await _accountService.GetByAdminCodeAsync(adminCode);
|
||||||
|
return Ok(ApiResponse<AccountResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{adminCode}/update")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 정보 수정",
|
||||||
|
Description = "Super Admin이 운영자의 정보(이름, 전화번호, 권한)를 수정합니다.")]
|
||||||
|
[SwaggerResponse(200, "수정 성공", typeof(ApiResponse<AccountResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> UpdateAsync(
|
||||||
|
[FromRoute] string adminCode,
|
||||||
|
[FromBody] UpdateAccountRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _accountService.UpdateAsync(adminCode, request);
|
||||||
|
return Ok(ApiResponse<AccountResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{adminCode}/delete")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 계정 삭제",
|
||||||
|
Description = "Super Admin이 운영자 계정을 삭제합니다. (Soft Delete)")]
|
||||||
|
[SwaggerResponse(200, "삭제 성공")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromRoute] string adminCode)
|
||||||
|
{
|
||||||
|
await _accountService.DeleteAsync(adminCode);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("operator/create")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 계정 생성 (이메일 링크)",
|
||||||
|
Description = "Super Admin이 운영자 계정을 생성합니다. 비밀번호 설정 이메일이 발송됩니다.")]
|
||||||
|
[SwaggerResponse(200, "생성 성공", typeof(ApiResponse<OperatorCreateResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(409, "이메일 중복")]
|
||||||
|
public async Task<IActionResult> CreateOperatorAsync([FromBody] OperatorCreateRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _accountService.CreateOperatorAsync(request);
|
||||||
|
return Ok(ApiResponse<OperatorCreateResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("operator/delete")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 계정 삭제",
|
||||||
|
Description = "Super Admin이 운영자 계정을 삭제합니다. (Soft Delete) 자기 자신은 삭제할 수 없습니다.")]
|
||||||
|
[SwaggerResponse(200, "삭제 성공")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음 / 자기 자신 삭제 불가")]
|
||||||
|
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> DeleteOperatorAsync([FromBody] OperatorDeleteRequestDto request)
|
||||||
|
{
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
await _accountService.DeleteOperatorAsync(request.AdminCode, adminId);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("operator/list")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 목록 조회",
|
||||||
|
Description = "Super Admin이 운영자(Manager/User) 목록을 조회합니다. is_active 필터로 비활성 운영자도 조회 가능합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<OperatorListResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
public async Task<IActionResult> GetOperatorListAsync([FromBody] OperatorListRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _accountService.GetOperatorListAsync(request);
|
||||||
|
return Ok(ApiResponse<OperatorListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("operator/password/reset")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "운영자 비밀번호 초기화",
|
||||||
|
Description = "Super Admin이 운영자의 비밀번호를 초기화합니다. 비밀번호 재설정 이메일이 발송됩니다.")]
|
||||||
|
[SwaggerResponse(200, "초기화 성공", typeof(ApiResponse<OperatorCreateResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> ResetOperatorPasswordAsync([FromBody] OperatorPasswordResetRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _accountService.ResetOperatorPasswordAsync(request.AdminCode);
|
||||||
|
return Ok(ApiResponse<OperatorCreateResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetAdminId()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
{
|
||||||
|
throw new SPMS.Domain.Exceptions.SpmsException(
|
||||||
|
ErrorCodes.Unauthorized,
|
||||||
|
"인증 정보를 확인할 수 없습니다.",
|
||||||
|
401);
|
||||||
|
}
|
||||||
|
return adminId;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SPMS.API/Controllers/AppConfigController.cs
Normal file
28
SPMS.API/Controllers/AppConfigController.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.AppConfig;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class AppConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAppConfigService _appConfigService;
|
||||||
|
|
||||||
|
public AppConfigController(IAppConfigService appConfigService)
|
||||||
|
{
|
||||||
|
_appConfigService = appConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("config")]
|
||||||
|
[SwaggerOperation(Summary = "앱 기본 설정", Description = "앱 기본 설정 정보를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetAppSettingsAsync()
|
||||||
|
{
|
||||||
|
var result = await _appConfigService.GetAppSettingsAsync();
|
||||||
|
return Ok(ApiResponse<AppSettingsResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SPMS.API/Controllers/AppVersionController.cs
Normal file
28
SPMS.API/Controllers/AppVersionController.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.AppConfig;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public/app")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class AppVersionController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAppConfigService _appConfigService;
|
||||||
|
|
||||||
|
public AppVersionController(IAppConfigService appConfigService)
|
||||||
|
{
|
||||||
|
_appConfigService = appConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("version")]
|
||||||
|
[SwaggerOperation(Summary = "앱 버전 체크", Description = "현재 앱 버전 정보를 확인하고 업데이트 필요 여부를 반환합니다.")]
|
||||||
|
public async Task<IActionResult> CheckVersionAsync([FromBody] AppVersionRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _appConfigService.GetAppVersionAsync(request);
|
||||||
|
return Ok(ApiResponse<AppVersionResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
150
SPMS.API/Controllers/AuthController.cs
Normal file
150
SPMS.API/Controllers/AuthController.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Auth;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/auth")]
|
||||||
|
[ApiExplorerSettings(GroupName = "auth")]
|
||||||
|
public class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuthService _authService;
|
||||||
|
|
||||||
|
public AuthController(IAuthService authService)
|
||||||
|
{
|
||||||
|
_authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("signup")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "회원가입",
|
||||||
|
Description = "새로운 관리자 계정을 생성합니다. 약관/개인정보 동의(agreeTerms, agreePrivacy)가 필수이며, 성공 시 이메일 인증 세션(verifySessionId)과 메일 발송 결과(emailSent)를 반환합니다.")]
|
||||||
|
[SwaggerResponse(200, "회원가입 성공", typeof(ApiResponse<SignupResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청 또는 동의 미체크")]
|
||||||
|
[SwaggerResponse(409, "이미 사용 중인 이메일")]
|
||||||
|
public async Task<IActionResult> SignupAsync([FromBody] SignupRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _authService.SignupAsync(request);
|
||||||
|
return Ok(ApiResponse<SignupResponseDto>.Success(result, "회원가입 완료. 이메일 인증이 필요합니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("email/check")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "이메일 중복 체크",
|
||||||
|
Description = "회원가입 전 이메일 사용 가능 여부를 확인합니다.")]
|
||||||
|
[SwaggerResponse(200, "이메일 중복 체크 성공", typeof(ApiResponse<EmailCheckResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
public async Task<IActionResult> CheckEmailAsync([FromBody] EmailCheckRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _authService.CheckEmailAsync(request);
|
||||||
|
return Ok(ApiResponse<EmailCheckResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("auth_sensitive")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "관리자 로그인",
|
||||||
|
Description = "이메일과 비밀번호로 로그인하여 JWT 토큰을 발급받습니다. " +
|
||||||
|
"응답의 nextAction으로 화면 분기: GO_DASHBOARD(대시보드), VERIFY_EMAIL(이메일 인증 필요), CHANGE_PASSWORD(비밀번호 강제변경). " +
|
||||||
|
"5회 연속 실패 시 15분간 로그인이 차단됩니다.")]
|
||||||
|
[SwaggerResponse(200, "로그인 성공", typeof(ApiResponse<LoginResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "로그인 실패")]
|
||||||
|
[SwaggerResponse(429, "로그인 시도 횟수 초과")]
|
||||||
|
public async Task<IActionResult> LoginAsync([FromBody] LoginRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _authService.LoginAsync(request);
|
||||||
|
return Ok(ApiResponse<LoginResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("token/refresh")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "토큰 갱신",
|
||||||
|
Description = "Refresh Token을 사용하여 새로운 Access Token과 Refresh Token을 발급받습니다.")]
|
||||||
|
[SwaggerResponse(200, "토큰 갱신 성공", typeof(ApiResponse<TokenRefreshResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "유효하지 않거나 만료된 Refresh Token")]
|
||||||
|
public async Task<IActionResult> RefreshTokenAsync([FromBody] TokenRefreshRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _authService.RefreshTokenAsync(request);
|
||||||
|
return Ok(ApiResponse<TokenRefreshResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[Authorize]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "로그아웃",
|
||||||
|
Description = "현재 로그인된 관리자의 토큰을 무효화합니다. Refresh Token은 DB에서 삭제되고, Access Token은 Redis 블랙리스트에 등록되어 즉시 사용 불가합니다. " +
|
||||||
|
"설정/마이페이지/프로필 등 모든 화면에서 이 단일 API를 사용합니다. 응답의 redirect_to로 이동합니다.")]
|
||||||
|
[SwaggerResponse(200, "로그아웃 성공", typeof(ApiResponse<LogoutResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
public async Task<IActionResult> LogoutAsync()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var accessToken = HttpContext.Request.Headers["Authorization"]
|
||||||
|
.ToString().Replace("Bearer ", "");
|
||||||
|
|
||||||
|
var result = await _authService.LogoutAsync(adminId, accessToken);
|
||||||
|
return Ok(ApiResponse<LogoutResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("email/verify")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("auth_sensitive")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "이메일 인증",
|
||||||
|
Description = "인증 코드로 이메일을 인증합니다. verify_session_id(권장) 또는 email로 대상을 지정합니다. 5회 실패 시 30분간 차단됩니다.")]
|
||||||
|
[SwaggerResponse(200, "이메일 인증 성공", typeof(ApiResponse<EmailVerifyResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "인증 코드 불일치 또는 만료")]
|
||||||
|
[SwaggerResponse(429, "시도 횟수 초과")]
|
||||||
|
public async Task<IActionResult> VerifyEmailAsync([FromBody] EmailVerifyRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _authService.VerifyEmailAsync(request);
|
||||||
|
return Ok(ApiResponse<EmailVerifyResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("email/verify/resend")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("auth_sensitive")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "이메일 인증코드 재전송",
|
||||||
|
Description = "인증 세션 ID로 인증코드를 재전송합니다. 재전송 간 60초 쿨다운이 적용되며, 인증코드 유효시간은 5분입니다.")]
|
||||||
|
[SwaggerResponse(200, "재전송 성공", typeof(ApiResponse<EmailResendResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "유효하지 않은 세션")]
|
||||||
|
[SwaggerResponse(429, "재전송 쿨다운 중")]
|
||||||
|
public async Task<IActionResult> ResendVerificationAsync([FromBody] EmailResendRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _authService.ResendVerificationAsync(request);
|
||||||
|
return Ok(ApiResponse<EmailResendResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("password/change")]
|
||||||
|
[Authorize]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "비밀번호 변경",
|
||||||
|
Description = "현재 로그인된 관리자의 비밀번호를 변경합니다. 변경 성공 시 모든 세션이 무효화되며 재로그인이 필요합니다. " +
|
||||||
|
"비밀번호 정책: 8자 이상, 영문 대/소문자·숫자·특수문자 각 1자 이상, 현재 비밀번호와 동일 불가.")]
|
||||||
|
[SwaggerResponse(200, "비밀번호 변경 성공", typeof(ApiResponse<ChangePasswordResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "현재 비밀번호 불일치 / 정책 위반 / 동일 비밀번호 재사용")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
public async Task<IActionResult> ChangePasswordAsync([FromBody] ChangePasswordRequestDto request)
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var result = await _authService.ChangePasswordAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<ChangePasswordResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SPMS.API/Controllers/BannerController.cs
Normal file
28
SPMS.API/Controllers/BannerController.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Banner;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public/banner")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class BannerController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBannerService _bannerService;
|
||||||
|
|
||||||
|
public BannerController(IBannerService bannerService)
|
||||||
|
{
|
||||||
|
_bannerService = bannerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "배너 목록", Description = "활성화된 배너 목록을 조회합니다. position으로 필터링 가능.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] BannerListRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _bannerService.GetListAsync(request);
|
||||||
|
return Ok(ApiResponse<BannerListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
120
SPMS.API/Controllers/DeviceController.cs
Normal file
120
SPMS.API/Controllers/DeviceController.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Device;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/device")]
|
||||||
|
[ApiExplorerSettings(GroupName = "device")]
|
||||||
|
public class DeviceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDeviceService _deviceService;
|
||||||
|
|
||||||
|
public DeviceController(IDeviceService deviceService)
|
||||||
|
{
|
||||||
|
_deviceService = deviceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[SwaggerOperation(Summary = "디바이스 등록", Description = "앱 최초 설치 시 디바이스를 등록합니다.")]
|
||||||
|
public async Task<IActionResult> RegisterAsync([FromBody] DeviceRegisterRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _deviceService.RegisterAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<DeviceRegisterResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("info")]
|
||||||
|
[SwaggerOperation(Summary = "디바이스 조회", Description = "디바이스 정보를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetInfoAsync([FromBody] DeviceInfoRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _deviceService.GetInfoAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<DeviceInfoResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("update")]
|
||||||
|
[SwaggerOperation(Summary = "디바이스 수정", Description = "앱 실행 시 디바이스 정보를 업데이트합니다.")]
|
||||||
|
public async Task<IActionResult> UpdateAsync([FromBody] DeviceUpdateRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _deviceService.UpdateAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[SwaggerOperation(Summary = "디바이스 삭제", Description = "앱 삭제/로그아웃 시 디바이스를 비활성화합니다.")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromBody] DeviceDeleteRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _deviceService.DeleteAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tags")]
|
||||||
|
[SwaggerOperation(Summary = "태그 설정", Description = "디바이스 태그를 설정합니다. 빈 배열 전달 시 모든 태그 해제.")]
|
||||||
|
public async Task<IActionResult> SetTagsAsync([FromBody] DeviceTagsRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _deviceService.SetTagsAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("agree")]
|
||||||
|
[SwaggerOperation(Summary = "동의 설정", Description = "푸시/마케팅 수신 동의를 설정합니다.")]
|
||||||
|
public async Task<IActionResult> SetAgreeAsync([FromBody] DeviceAgreeRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _deviceService.SetAgreeAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("admin/delete")]
|
||||||
|
[Authorize]
|
||||||
|
[SwaggerOperation(Summary = "관리자 기기 삭제", Description = "관리자가 기기를 삭제(비활성화)합니다. 삭제 즉시 발송이 차단됩니다. JWT 인증 필요.")]
|
||||||
|
public async Task<IActionResult> AdminDeleteAsync([FromBody] DeviceDeleteRequestDto request)
|
||||||
|
{
|
||||||
|
await _deviceService.AdminDeleteAsync(request.DeviceId);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[Authorize]
|
||||||
|
[SwaggerOperation(Summary = "디바이스 목록", Description = "대시보드에서 디바이스 목록을 조회합니다. JWT 인증 필요.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] DeviceListRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _deviceService.GetListAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<DeviceListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("export")]
|
||||||
|
[Authorize]
|
||||||
|
[SwaggerOperation(Summary = "기기 엑셀 내보내기", Description = "목록 필터와 동일 조건으로 기기 목록을 엑셀(.xlsx)로 내보냅니다. JWT 인증 필요.")]
|
||||||
|
public async Task<IActionResult> ExportAsync([FromBody] DeviceExportRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var fileBytes = await _deviceService.ExportAsync(serviceId, request);
|
||||||
|
var fileName = $"device_export_{DateTime.UtcNow:yyyyMMdd}.xlsx";
|
||||||
|
return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
|
||||||
|
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long? GetOptionalServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var obj) && obj is long id)
|
||||||
|
return id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SPMS.API/Controllers/FaqController.cs
Normal file
28
SPMS.API/Controllers/FaqController.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Faq;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public/faq")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class FaqController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IFaqService _faqService;
|
||||||
|
|
||||||
|
public FaqController(IFaqService faqService)
|
||||||
|
{
|
||||||
|
_faqService = faqService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "FAQ 목록", Description = "활성화된 FAQ 목록을 조회합니다. category로 필터링 가능.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] FaqListRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _faqService.GetListAsync(request);
|
||||||
|
return Ok(ApiResponse<FaqListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
105
SPMS.API/Controllers/FileController.cs
Normal file
105
SPMS.API/Controllers/FileController.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.File;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/file")]
|
||||||
|
[Authorize]
|
||||||
|
[ApiExplorerSettings(GroupName = "file")]
|
||||||
|
public class FileController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IFileService _fileService;
|
||||||
|
|
||||||
|
public FileController(IFileService fileService)
|
||||||
|
{
|
||||||
|
_fileService = fileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("upload")]
|
||||||
|
[SwaggerOperation(Summary = "파일 업로드", Description = "이미지 또는 CSV 파일을 업로드합니다.")]
|
||||||
|
[RequestSizeLimit(52_428_800)] // 50MB
|
||||||
|
public async Task<IActionResult> UploadAsync(IFormFile file, [FromForm] string file_type)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var result = await _fileService.UploadAsync(
|
||||||
|
serviceId, adminId, stream, file.FileName, file.Length, file_type);
|
||||||
|
|
||||||
|
return Ok(ApiResponse<FileUploadResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("info")]
|
||||||
|
[SwaggerOperation(Summary = "파일 조회", Description = "파일 메타데이터를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetInfoAsync([FromBody] FileInfoRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _fileService.GetInfoAsync(serviceId, request.FileId);
|
||||||
|
return Ok(ApiResponse<FileInfoResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "파일 목록 조회", Description = "서비스의 파일 목록을 페이징 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] FileListRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _fileService.GetListAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<FileListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[SwaggerOperation(Summary = "파일 삭제", Description = "파일을 삭제합니다. (Soft Delete)")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromBody] FileDeleteRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _fileService.DeleteAsync(serviceId, request.FileId);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("csv/validate")]
|
||||||
|
[SwaggerOperation(Summary = "CSV 검증", Description = "대용량 발송용 CSV 파일을 검증합니다.")]
|
||||||
|
[RequestSizeLimit(52_428_800)] // 50MB
|
||||||
|
public async Task<IActionResult> ValidateCsvAsync(IFormFile file, [FromForm] string message_code)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var result = await _fileService.ValidateCsvAsync(serviceId, stream, file.FileName, message_code);
|
||||||
|
|
||||||
|
return Ok(ApiResponse<CsvValidateResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("csv/template")]
|
||||||
|
[SwaggerOperation(Summary = "CSV 템플릿 다운로드", Description = "메시지별 CSV 템플릿을 다운로드합니다.")]
|
||||||
|
public async Task<IActionResult> GetCsvTemplateAsync([FromBody] CsvTemplateRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var csvBytes = await _fileService.GetCsvTemplateAsync(serviceId, request.MessageCode);
|
||||||
|
|
||||||
|
return File(csvBytes, "text/csv", $"template_{request.MessageCode}.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
|
||||||
|
throw new SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetAdminId()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401);
|
||||||
|
|
||||||
|
return adminId;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SPMS.API/Controllers/MaintenanceController.cs
Normal file
28
SPMS.API/Controllers/MaintenanceController.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.AppConfig;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class MaintenanceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAppConfigService _appConfigService;
|
||||||
|
|
||||||
|
public MaintenanceController(IAppConfigService appConfigService)
|
||||||
|
{
|
||||||
|
_appConfigService = appConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("maintenance")]
|
||||||
|
[SwaggerOperation(Summary = "점검 안내", Description = "현재 서비스 점검 상태를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetMaintenanceAsync()
|
||||||
|
{
|
||||||
|
var result = await _appConfigService.GetMaintenanceAsync();
|
||||||
|
return Ok(ApiResponse<MaintenanceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
103
SPMS.API/Controllers/MessageController.cs
Normal file
103
SPMS.API/Controllers/MessageController.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Message;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/message")]
|
||||||
|
[ApiExplorerSettings(GroupName = "message")]
|
||||||
|
[Authorize]
|
||||||
|
public class MessageController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMessageValidationService _validationService;
|
||||||
|
private readonly IMessageService _messageService;
|
||||||
|
|
||||||
|
public MessageController(IMessageValidationService validationService, IMessageService messageService)
|
||||||
|
{
|
||||||
|
_validationService = validationService;
|
||||||
|
_messageService = messageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("save")]
|
||||||
|
[SwaggerOperation(Summary = "메시지 저장", Description = "메시지 템플릿을 저장합니다. 메시지 코드가 자동 생성됩니다. 필드명은 snake_case(title, body, image_url, link_url, link_type, data)를 사용합니다. 저장 전 validate API로 사전 검증을 권장합니다.")]
|
||||||
|
public async Task<IActionResult> SaveAsync([FromBody] MessageSaveRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
var result = await _messageService.SaveAsync(serviceId, adminId, request);
|
||||||
|
return Ok(ApiResponse<MessageSaveResponseDto>.Success(result, "메시지 저장 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "메시지 목록 조회", Description = "메시지 목록을 페이지 단위로 조회합니다. X-Service-Code 헤더가 있으면 해당 서비스만, 없으면 전체 서비스 메시지를 반환합니다. send_status 필터(complete/pending/failed)를 지원합니다. 발송 상태는 통일된 SendStatus 규칙(pending=미발송, complete=1건 이상 성공, failed=전건 실패)으로 판정됩니다.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] MessageListRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceIdOrNull();
|
||||||
|
var result = await _messageService.GetListAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<MessageListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("info")]
|
||||||
|
[SwaggerOperation(Summary = "메시지 상세 조회", Description = "메시지 코드로 상세 정보를 조회합니다. 서비스 정보(service_name, service_code), 작성자(created_by_name), 발송 상태(latest_send_status), 템플릿 변수 목록을 포함합니다. 발송 상태는 통일된 SendStatus 규칙(pending=미발송, complete=1건 이상 성공, failed=전건 실패)으로 판정됩니다.")]
|
||||||
|
public async Task<IActionResult> GetInfoAsync([FromBody] MessageInfoRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _messageService.GetInfoAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<MessageInfoResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[SwaggerOperation(Summary = "메시지 삭제", Description = "메시지를 소프트 삭제합니다. 30일 후 스케줄러에 의해 완전 삭제됩니다.")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromBody] MessageDeleteRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _messageService.DeleteAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<object?>.Success(null, "메시지 삭제 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("validate")]
|
||||||
|
[SwaggerOperation(Summary = "메시지 유효성 검사", Description = "메시지 내용의 유효성을 검사합니다. save API와 동일한 snake_case 필드명(title, body, image_url, link_url, link_type, data)을 사용합니다. 검증 실패 시 data.errors[]에 field + message 단위로 오류가 반환됩니다.")]
|
||||||
|
public IActionResult ValidateAsync([FromBody] MessageValidateRequestDto request)
|
||||||
|
{
|
||||||
|
var result = _validationService.Validate(request);
|
||||||
|
return Ok(ApiResponse<MessageValidationResultDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("preview")]
|
||||||
|
[SwaggerOperation(Summary = "메시지 미리보기", Description = "메시지 템플릿에 변수를 치환하여 미리보기를 생성합니다. 응답에 link_type을 포함합니다.")]
|
||||||
|
public async Task<IActionResult> PreviewAsync([FromBody] MessagePreviewRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _messageService.PreviewAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<MessagePreviewResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
|
||||||
|
throw new SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long? GetServiceIdOrNull()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetAdminId()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401);
|
||||||
|
|
||||||
|
return adminId;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
SPMS.API/Controllers/NoticeController.cs
Normal file
36
SPMS.API/Controllers/NoticeController.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Notice;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public/notice")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class NoticeController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly INoticeService _noticeService;
|
||||||
|
|
||||||
|
public NoticeController(INoticeService noticeService)
|
||||||
|
{
|
||||||
|
_noticeService = noticeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "공지사항 목록", Description = "활성화된 공지사항 목록을 페이징으로 조회합니다. 상단 고정 우선 정렬.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] NoticeListRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _noticeService.GetListAsync(request);
|
||||||
|
return Ok(ApiResponse<NoticeListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("info")]
|
||||||
|
[SwaggerOperation(Summary = "공지사항 상세", Description = "공지사항 ID로 상세 정보를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetInfoAsync([FromBody] NoticeInfoRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _noticeService.GetInfoAsync(request);
|
||||||
|
return Ok(ApiResponse<NoticeInfoResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
68
SPMS.API/Controllers/NotificationController.cs
Normal file
68
SPMS.API/Controllers/NotificationController.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Notification;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/notification")]
|
||||||
|
[Authorize]
|
||||||
|
[ApiExplorerSettings(GroupName = "notification")]
|
||||||
|
public class NotificationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly INotificationService _notificationService;
|
||||||
|
|
||||||
|
public NotificationController(INotificationService notificationService)
|
||||||
|
{
|
||||||
|
_notificationService = notificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("summary")]
|
||||||
|
[SwaggerOperation(Summary = "알림 요약 조회", Description = "최근 N건의 알림과 미읽 건수를 반환합니다. 헤더 뱃지용.")]
|
||||||
|
public async Task<IActionResult> GetSummaryAsync([FromBody] NotificationSummaryRequestDto request)
|
||||||
|
{
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
var result = await _notificationService.GetSummaryAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<NotificationSummaryResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "알림 목록 조회", Description = "알림 목록을 페이지 단위로 조회합니다. 카테고리/기간/읽음 필터를 지원합니다.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] NotificationListRequestDto request)
|
||||||
|
{
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
var result = await _notificationService.GetListAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<NotificationListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("read")]
|
||||||
|
[SwaggerOperation(Summary = "알림 읽음 처리", Description = "단건 알림을 읽음 처리합니다. 이미 읽은 알림은 무시(멱등).")]
|
||||||
|
public async Task<IActionResult> MarkAsReadAsync([FromBody] NotificationReadRequestDto request)
|
||||||
|
{
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
var result = await _notificationService.MarkAsReadAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<NotificationReadResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("read-all")]
|
||||||
|
[SwaggerOperation(Summary = "알림 전체 읽음", Description = "해당 관리자의 모든 미읽 알림을 일괄 읽음 처리합니다.")]
|
||||||
|
public async Task<IActionResult> MarkAllAsReadAsync()
|
||||||
|
{
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
var result = await _notificationService.MarkAllAsReadAsync(adminId);
|
||||||
|
return Ok(ApiResponse<NotificationReadResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetAdminId()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401);
|
||||||
|
|
||||||
|
return adminId;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
SPMS.API/Controllers/PasswordController.cs
Normal file
61
SPMS.API/Controllers/PasswordController.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Account;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/account/password")]
|
||||||
|
[ApiExplorerSettings(GroupName = "account")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class PasswordController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuthService _authService;
|
||||||
|
|
||||||
|
public PasswordController(IAuthService authService)
|
||||||
|
{
|
||||||
|
_authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("forgot")]
|
||||||
|
[EnableRateLimiting("auth_sensitive")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "비밀번호 찾기",
|
||||||
|
Description = "등록된 이메일로 비밀번호 재설정 토큰을 발송합니다. 보안을 위해 이메일 존재 여부와 관계없이 동일한 응답을 반환합니다.")]
|
||||||
|
[SwaggerResponse(200, "재설정 메일 발송 완료")]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
public async Task<IActionResult> ForgotPasswordAsync([FromBody] PasswordForgotRequestDto request)
|
||||||
|
{
|
||||||
|
await _authService.ForgotPasswordAsync(request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("reset")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "비밀번호 재설정",
|
||||||
|
Description = "이메일로 받은 재설정 토큰과 새 비밀번호로 비밀번호를 재설정합니다.")]
|
||||||
|
[SwaggerResponse(200, "비밀번호 재설정 성공")]
|
||||||
|
[SwaggerResponse(400, "토큰 불일치 또는 만료")]
|
||||||
|
public async Task<IActionResult> ResetPasswordAsync([FromBody] PasswordResetRequestDto request)
|
||||||
|
{
|
||||||
|
await _authService.ResetPasswordAsync(request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("temp")]
|
||||||
|
[EnableRateLimiting("auth_sensitive")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "임시 비밀번호 발급",
|
||||||
|
Description = "등록된 이메일로 임시 비밀번호를 발송합니다. 보안을 위해 이메일 존재 여부와 관계없이 동일한 응답을 반환합니다.")]
|
||||||
|
[SwaggerResponse(200, "임시 비밀번호 발송 완료")]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
public async Task<IActionResult> IssueTempPasswordAsync([FromBody] TempPasswordRequestDto request)
|
||||||
|
{
|
||||||
|
await _authService.IssueTempPasswordAsync(request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
}
|
||||||
72
SPMS.API/Controllers/ProfileController.cs
Normal file
72
SPMS.API/Controllers/ProfileController.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Account;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/account/profile")]
|
||||||
|
[ApiExplorerSettings(GroupName = "account")]
|
||||||
|
[Authorize]
|
||||||
|
public class ProfileController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuthService _authService;
|
||||||
|
|
||||||
|
public ProfileController(IAuthService authService)
|
||||||
|
{
|
||||||
|
_authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("info")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "내 정보 조회",
|
||||||
|
Description = "현재 로그인된 관리자의 프로필 정보를 조회합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ProfileResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
public async Task<IActionResult> GetProfileAsync()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var result = await _authService.GetProfileAsync(adminId);
|
||||||
|
return Ok(ApiResponse<ProfileResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("update")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "내 정보 수정",
|
||||||
|
Description = "현재 로그인된 관리자의 프로필 정보(이름, 전화번호)를 수정합니다.")]
|
||||||
|
[SwaggerResponse(200, "수정 성공", typeof(ApiResponse<ProfileResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "변경된 내용 없음")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
public async Task<IActionResult> UpdateProfileAsync([FromBody] UpdateProfileRequestDto request)
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var result = await _authService.UpdateProfileAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<ProfileResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("activity/list")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "활동 내역 조회",
|
||||||
|
Description = "현재 로그인된 관리자의 활동 내역을 페이징 조회합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ActivityListResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
public async Task<IActionResult> GetActivityListAsync([FromBody] ActivityListRequestDto request)
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var result = await _authService.GetActivityListAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<ActivityListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
98
SPMS.API/Controllers/PublicController.cs
Normal file
98
SPMS.API/Controllers/PublicController.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Infrastructure;
|
||||||
|
using SPMS.Infrastructure.Caching;
|
||||||
|
using SPMS.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/out")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class PublicController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _dbContext;
|
||||||
|
private readonly RedisConnection _redisConnection;
|
||||||
|
private readonly RabbitMQConnection _rabbitConnection;
|
||||||
|
private readonly RabbitMQInitializer _rabbitInitializer;
|
||||||
|
|
||||||
|
public PublicController(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
RedisConnection redisConnection,
|
||||||
|
RabbitMQConnection rabbitConnection,
|
||||||
|
RabbitMQInitializer rabbitInitializer)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_redisConnection = redisConnection;
|
||||||
|
_rabbitConnection = rabbitConnection;
|
||||||
|
_rabbitInitializer = rabbitInitializer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("health")]
|
||||||
|
[SwaggerOperation(Summary = "서버 상태 확인", Description = "MariaDB, Redis, RabbitMQ 연결 상태를 확인합니다.")]
|
||||||
|
public async Task<IActionResult> HealthCheckAsync()
|
||||||
|
{
|
||||||
|
var checks = new Dictionary<string, object>();
|
||||||
|
var allHealthy = true;
|
||||||
|
|
||||||
|
// 1. MariaDB 연결 확인
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dbContext.Database.ExecuteSqlRawAsync("SELECT 1");
|
||||||
|
checks["database"] = new { status = "healthy" };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
checks["database"] = new { status = "unhealthy", error = ex.Message };
|
||||||
|
allHealthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Redis 연결 확인
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = await _redisConnection.GetDatabaseAsync();
|
||||||
|
var pong = await db.PingAsync();
|
||||||
|
checks["redis"] = new { status = "healthy", latency = $"{pong.TotalMilliseconds:F1}ms" };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
checks["redis"] = new { status = "unhealthy", error = ex.Message };
|
||||||
|
allHealthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. RabbitMQ 연결 확인
|
||||||
|
var rabbitConnected = _rabbitConnection.IsConnected;
|
||||||
|
var rabbitInitialized = _rabbitInitializer.IsInitialized;
|
||||||
|
if (rabbitConnected && rabbitInitialized)
|
||||||
|
{
|
||||||
|
checks["rabbitmq"] = new { status = "healthy" };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
checks["rabbitmq"] = new
|
||||||
|
{
|
||||||
|
status = "unhealthy",
|
||||||
|
connected = rabbitConnected,
|
||||||
|
initialized = rabbitInitialized
|
||||||
|
};
|
||||||
|
allHealthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allHealthy)
|
||||||
|
{
|
||||||
|
return Ok(ApiResponse<object>.Success(checks));
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatusCode(503, new ApiResponse<object>
|
||||||
|
{
|
||||||
|
Result = false,
|
||||||
|
Code = ErrorCodes.InternalError,
|
||||||
|
Msg = "하나 이상의 서비스에 문제가 있습니다.",
|
||||||
|
Data = checks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
120
SPMS.API/Controllers/PushController.cs
Normal file
120
SPMS.API/Controllers/PushController.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Push;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/push")]
|
||||||
|
[ApiExplorerSettings(GroupName = "push")]
|
||||||
|
[Authorize]
|
||||||
|
public class PushController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPushService _pushService;
|
||||||
|
|
||||||
|
public PushController(IPushService pushService)
|
||||||
|
{
|
||||||
|
_pushService = pushService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send")]
|
||||||
|
[SwaggerOperation(Summary = "단건 발송", Description = "특정 디바이스에 푸시 메시지를 즉시 발송합니다.")]
|
||||||
|
public async Task<IActionResult> SendAsync([FromBody] PushSendRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _pushService.SendAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<PushSendResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send/tag")]
|
||||||
|
[SwaggerOperation(Summary = "태그 발송", Description = "태그 조건에 해당하는 디바이스에 푸시 메시지를 발송합니다.")]
|
||||||
|
public async Task<IActionResult> SendByTagAsync([FromBody] PushSendTagRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _pushService.SendByTagAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<PushSendResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("schedule")]
|
||||||
|
[SwaggerOperation(Summary = "예약 발송", Description = "지정 시간에 푸시 메시지 발송을 예약합니다.")]
|
||||||
|
public async Task<IActionResult> ScheduleAsync([FromBody] PushScheduleRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _pushService.ScheduleAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<PushScheduleResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("schedule/cancel")]
|
||||||
|
[SwaggerOperation(Summary = "예약 취소", Description = "예약된 발송을 취소합니다.")]
|
||||||
|
public async Task<IActionResult> CancelScheduleAsync([FromBody] PushScheduleCancelRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _pushService.CancelScheduleAsync(request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("log")]
|
||||||
|
[SwaggerOperation(Summary = "발송 로그 조회", Description = "푸시 발송 이력을 페이지 단위로 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetLogAsync([FromBody] PushLogRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _pushService.GetLogAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<PushLogResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("log/export")]
|
||||||
|
[SwaggerOperation(Summary = "발송 로그 내보내기", Description = "발송 로그를 CSV 파일로 다운로드합니다. 최대 30일, 100,000건.")]
|
||||||
|
public async Task<IActionResult> ExportLogAsync([FromBody] PushLogExportRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var fileBytes = await _pushService.ExportLogAsync(serviceId, request);
|
||||||
|
var fileName = $"push_log_{request.StartDate}_{request.EndDate}.csv";
|
||||||
|
return File(fileBytes, "text/csv", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send/bulk")]
|
||||||
|
[SwaggerOperation(Summary = "대용량 발송", Description = "CSV 파일로 대량 푸시 발송을 요청합니다.")]
|
||||||
|
public async Task<IActionResult> SendBulkAsync(IFormFile file, [FromForm(Name = "message_code")] string messageCode)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "CSV 파일은 필수입니다.", 400);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(messageCode))
|
||||||
|
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400);
|
||||||
|
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var result = await _pushService.SendBulkAsync(serviceId, stream, messageCode);
|
||||||
|
return Ok(ApiResponse<BulkSendResponseDto>.Success(result, "발송 요청이 접수되었습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("job/status")]
|
||||||
|
[SwaggerOperation(Summary = "발송 상태 조회", Description = "대용량/태그 발송 작업의 상태를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetJobStatusAsync([FromBody] JobStatusRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _pushService.GetJobStatusAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<JobStatusResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("job/cancel")]
|
||||||
|
[SwaggerOperation(Summary = "발송 취소", Description = "대기 중이거나 처리 중인 작업을 취소합니다.")]
|
||||||
|
public async Task<IActionResult> CancelJobAsync([FromBody] JobCancelRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _pushService.CancelJobAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<JobCancelResponseDto>.Success(result, "발송이 취소되었습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
|
||||||
|
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
343
SPMS.API/Controllers/ServiceController.cs
Normal file
343
SPMS.API/Controllers/ServiceController.cs
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Service;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/service")]
|
||||||
|
[ApiExplorerSettings(GroupName = "service")]
|
||||||
|
[Authorize]
|
||||||
|
public class ServiceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IServiceManagementService _serviceManagementService;
|
||||||
|
|
||||||
|
public ServiceController(IServiceManagementService serviceManagementService)
|
||||||
|
{
|
||||||
|
_serviceManagementService = serviceManagementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("name/check")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스명 중복 체크",
|
||||||
|
Description = "서비스 등록 전 서비스명 사용 가능 여부를 확인합니다.")]
|
||||||
|
[SwaggerResponse(200, "중복 체크 성공", typeof(ApiResponse<ServiceNameCheckResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
public async Task<IActionResult> CheckServiceNameAsync([FromBody] ServiceNameCheckRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.CheckServiceNameAsync(request);
|
||||||
|
return Ok(ApiResponse<ServiceNameCheckResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 통합 등록",
|
||||||
|
Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다. APNs는 AuthType(p8/p12)에 따라 필수 필드가 달라집니다.")]
|
||||||
|
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse<RegisterServiceResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
[SwaggerResponse(409, "서비스명 중복")]
|
||||||
|
public async Task<IActionResult> RegisterAsync([FromBody] RegisterServiceRequestDto request)
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var result = await _serviceManagementService.RegisterAsync(request, adminId);
|
||||||
|
return Ok(ApiResponse<RegisterServiceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("create")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 등록",
|
||||||
|
Description = "새로운 서비스를 등록합니다. ServiceCode와 API Key가 자동 생성되며, API Key는 응답에서 1회만 표시됩니다.")]
|
||||||
|
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse<CreateServiceResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(409, "이미 존재하는 서비스명")]
|
||||||
|
public async Task<IActionResult> CreateAsync([FromBody] CreateServiceRequestDto request)
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
|
||||||
|
|
||||||
|
var result = await _serviceManagementService.CreateAsync(request, adminId);
|
||||||
|
return Ok(ApiResponse<CreateServiceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("update")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 수정",
|
||||||
|
Description = "기존 서비스의 정보를 수정합니다. 서비스명, 설명, 웹훅 URL, 태그, 상태(Status)를 변경할 수 있습니다. Status 필드(0=Active, 1=Suspended)를 함께 전달하면 상태도 원자적으로 변경됩니다.")]
|
||||||
|
[SwaggerResponse(200, "수정 성공", typeof(ApiResponse<ServiceResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "변경된 내용 없음")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
[SwaggerResponse(409, "이미 존재하는 서비스명")]
|
||||||
|
public async Task<IActionResult> UpdateAsync([FromBody] UpdateServiceRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.UpdateAsync(request);
|
||||||
|
return Ok(ApiResponse<ServiceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 삭제",
|
||||||
|
Description = "서비스를 Soft Delete 처리합니다. IsDeleted=true, 상태를 Suspended로 변경합니다.")]
|
||||||
|
[SwaggerResponse(200, "삭제 성공", typeof(ApiResponse))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
[SwaggerResponse(409, "이미 삭제된 서비스")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromBody] DeleteServiceRequestDto request)
|
||||||
|
{
|
||||||
|
await _serviceManagementService.DeleteAsync(request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 목록 조회",
|
||||||
|
Description = "등록된 서비스 목록을 조회합니다. 페이징, 검색, 상태 필터를 지원합니다. 각 항목에 platforms 필드로 Android/iOS 자격증명 상태(credentialStatus: ok/warn/error)를 포함합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ServiceListResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] ServiceListRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.GetListAsync(request);
|
||||||
|
return Ok(ApiResponse<ServiceListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 상세 조회",
|
||||||
|
Description = "특정 서비스의 상세 정보를 조회합니다. API Key는 마스킹(앞 8자+********)되어 반환되며, 전체 키 조회는 apikey/view 엔드포인트를 사용합니다. apnsAuthType(p8/p12)과 platforms 필드로 각 플랫폼의 자격증명 상태(credentialStatus: ok/warn/error), 만료일(expiresAt) 정보를 포함합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ServiceResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> GetByServiceCodeAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.GetByServiceCodeAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse<ServiceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/status")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "서비스 상태 변경",
|
||||||
|
Description = "서비스의 상태를 변경합니다. (Active: 0, Suspended: 1)")]
|
||||||
|
[SwaggerResponse(200, "상태 변경 성공", typeof(ApiResponse<ServiceResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청 또는 이미 해당 상태")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> ChangeStatusAsync(
|
||||||
|
[FromRoute] string serviceCode,
|
||||||
|
[FromBody] ChangeServiceStatusRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.ChangeStatusAsync(serviceCode, request);
|
||||||
|
return Ok(ApiResponse<ServiceResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/apikey/view")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "API Key 전체 조회",
|
||||||
|
Description = "서비스의 API Key 전체 값을 조회합니다. 상세 조회 시 마스킹된 키 대신 전체 키를 확인할 때 사용합니다. 키 회전(재발급) 없이 현재 키를 반환합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ApiKeyRefreshResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> ViewApiKeyAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.ViewApiKeyAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse<ApiKeyRefreshResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/apikey/refresh")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "API Key 재발급",
|
||||||
|
Description = "서비스의 API Key를 재발급합니다. 기존 키는 즉시 무효화되며, 새 키는 1회만 표시됩니다.")]
|
||||||
|
[SwaggerResponse(200, "재발급 성공", typeof(ApiResponse<ApiKeyRefreshResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> RefreshApiKeyAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.RefreshApiKeyAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse<ApiKeyRefreshResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/apns")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "APNs 키 등록",
|
||||||
|
Description = "APNs 푸시 발송을 위한 인증 정보를 등록합니다. AuthType=p8: Key ID(10자리), Team ID(10자리), Private Key(.p8) 필수. AuthType=p12: CertificateBase64(Base64 인코딩된 .p12), CertPassword 필수. 타입 전환 시 이전 타입 필드는 자동 초기화됩니다.")]
|
||||||
|
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키/인증서 형식)")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> RegisterApnsCredentialsAsync(
|
||||||
|
[FromRoute] string serviceCode,
|
||||||
|
[FromBody] ApnsCredentialsRequestDto request)
|
||||||
|
{
|
||||||
|
await _serviceManagementService.RegisterApnsCredentialsAsync(serviceCode, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/fcm")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "FCM 키 등록",
|
||||||
|
Description = "FCM 푸시 발송을 위한 Service Account JSON을 등록합니다. Firebase Console에서 다운로드한 service-account.json 내용이 필요합니다.")]
|
||||||
|
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청 (유효하지 않은 JSON 형식)")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> RegisterFcmCredentialsAsync(
|
||||||
|
[FromRoute] string serviceCode,
|
||||||
|
[FromBody] FcmCredentialsRequestDto request)
|
||||||
|
{
|
||||||
|
await _serviceManagementService.RegisterFcmCredentialsAsync(serviceCode, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/apns/delete")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "APNs 자격증명 삭제",
|
||||||
|
Description = "서비스에 등록된 APNs 자격증명(BundleId, KeyId, TeamId, PrivateKey, Certificate 등)을 모두 삭제합니다.")]
|
||||||
|
[SwaggerResponse(200, "삭제 성공", typeof(ApiResponse))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스 또는 자격증명을 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> DeleteApnsCredentialsAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
await _serviceManagementService.DeleteApnsCredentialsAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/fcm/delete")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "FCM 자격증명 삭제",
|
||||||
|
Description = "서비스에 등록된 FCM Service Account JSON 자격증명을 삭제합니다.")]
|
||||||
|
[SwaggerResponse(200, "삭제 성공", typeof(ApiResponse))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스 또는 자격증명을 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> DeleteFcmCredentialsAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
await _serviceManagementService.DeleteFcmCredentialsAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/credentials")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "푸시 키 정보 조회",
|
||||||
|
Description = "서비스에 등록된 APNs/FCM 키의 메타 정보를 조회합니다. 민감 정보(Private Key)는 반환되지 않습니다. 각 플랫폼별 credentialStatus(ok/warn/error/none)와 statusReason 필드로 자격증명 상태 진단 결과를 제공합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<CredentialsResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> GetCredentialsAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.GetCredentialsAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse<CredentialsResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tags/list")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "태그 목록 조회",
|
||||||
|
Description = "서비스에 등록된 태그 목록을 조회합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<ServiceTagsResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> GetTagsAsync([FromBody] ServiceTagsRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.GetTagsAsync(request);
|
||||||
|
return Ok(ApiResponse<ServiceTagsResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tags/update")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "태그 수정",
|
||||||
|
Description = "서비스의 태그 목록을 수정합니다. 최대 10개까지 등록 가능합니다.")]
|
||||||
|
[SwaggerResponse(200, "수정 성공", typeof(ApiResponse<ServiceTagsResponseDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청 또는 변경 없음")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> UpdateTagsAsync([FromBody] UpdateServiceTagsRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.UpdateTagsAsync(request);
|
||||||
|
return Ok(ApiResponse<ServiceTagsResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("webhook/config")]
|
||||||
|
[SwaggerOperation(Summary = "웹훅 설정", Description = "서비스의 웹훅 URL과 구독 이벤트 타입을 설정합니다.")]
|
||||||
|
public async Task<IActionResult> ConfigureWebhookAsync([FromBody] WebhookConfigRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.ConfigureWebhookAsync(request.ServiceCode, request);
|
||||||
|
return Ok(ApiResponse<WebhookConfigResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("webhook/info")]
|
||||||
|
[SwaggerOperation(Summary = "웹훅 설정 조회", Description = "서비스의 현재 웹훅 설정을 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetWebhookConfigAsync([FromBody] WebhookInfoRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.GetWebhookConfigAsync(request.ServiceCode);
|
||||||
|
return Ok(ApiResponse<WebhookConfigResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/ip/list")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "IP 화이트리스트 조회",
|
||||||
|
Description = "서비스에 등록된 IP 화이트리스트 목록을 조회합니다.")]
|
||||||
|
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<IpListResponseDto>))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> GetIpListAsync([FromRoute] string serviceCode)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.GetIpListAsync(serviceCode);
|
||||||
|
return Ok(ApiResponse<IpListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/ip/add")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "IP 추가",
|
||||||
|
Description = "서비스의 IP 화이트리스트에 새 IP를 추가합니다. IPv4 형식만 지원합니다.")]
|
||||||
|
[SwaggerResponse(200, "추가 성공", typeof(ApiResponse<ServiceIpDto>))]
|
||||||
|
[SwaggerResponse(400, "잘못된 요청 (유효하지 않은 IP 형식)")]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||||
|
[SwaggerResponse(409, "이미 등록된 IP")]
|
||||||
|
public async Task<IActionResult> AddIpAsync(
|
||||||
|
[FromRoute] string serviceCode,
|
||||||
|
[FromBody] AddIpRequestDto request)
|
||||||
|
{
|
||||||
|
var result = await _serviceManagementService.AddIpAsync(serviceCode, request);
|
||||||
|
return Ok(ApiResponse<ServiceIpDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{serviceCode}/ip/delete")]
|
||||||
|
[SwaggerOperation(
|
||||||
|
Summary = "IP 삭제",
|
||||||
|
Description = "서비스의 IP 화이트리스트에서 IP를 삭제합니다.")]
|
||||||
|
[SwaggerResponse(200, "삭제 성공", typeof(ApiResponse))]
|
||||||
|
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||||
|
[SwaggerResponse(403, "권한 없음")]
|
||||||
|
[SwaggerResponse(404, "서비스 또는 IP를 찾을 수 없음")]
|
||||||
|
public async Task<IActionResult> DeleteIpAsync(
|
||||||
|
[FromRoute] string serviceCode,
|
||||||
|
[FromBody] DeleteIpRequestDto request)
|
||||||
|
{
|
||||||
|
await _serviceManagementService.DeleteIpAsync(serviceCode, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
}
|
||||||
149
SPMS.API/Controllers/StatsController.cs
Normal file
149
SPMS.API/Controllers/StatsController.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Stats;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/stats")]
|
||||||
|
[ApiExplorerSettings(GroupName = "stats")]
|
||||||
|
[Authorize]
|
||||||
|
public class StatsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IStatsService _statsService;
|
||||||
|
|
||||||
|
public StatsController(IStatsService statsService)
|
||||||
|
{
|
||||||
|
_statsService = statsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("daily")]
|
||||||
|
[SwaggerOperation(Summary = "일별 통계 조회", Description = "기간별 일별 발송/성공/실패/열람 통계를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<DailyStatResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetDailyAsync([FromBody] DailyStatRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetDailyAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<DailyStatResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("summary")]
|
||||||
|
[SwaggerOperation(Summary = "요약 통계 조회", Description = "대시보드 요약 통계를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SummaryStatResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetSummaryAsync()
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetSummaryAsync(serviceId);
|
||||||
|
return Ok(ApiResponse<SummaryStatResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("message")]
|
||||||
|
[SwaggerOperation(Summary = "메시지별 통계 조회", Description = "특정 메시지의 발송 통계를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MessageStatResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetMessageStatAsync([FromBody] MessageStatRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetMessageStatAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<MessageStatResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("hourly")]
|
||||||
|
[SwaggerOperation(Summary = "시간대별 통계 조회", Description = "시간대별 발송 추이를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<HourlyStatResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetHourlyAsync([FromBody] HourlyStatRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetHourlyAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<HourlyStatResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("device")]
|
||||||
|
[SwaggerOperation(Summary = "디바이스 통계 조회", Description = "플랫폼/모델별 디바이스 분포를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<DeviceStatResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetDeviceStatAsync()
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetDeviceStatAsync(serviceId);
|
||||||
|
return Ok(ApiResponse<DeviceStatResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("export")]
|
||||||
|
[SwaggerOperation(Summary = "통계 리포트 다운로드", Description = "일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 파일로 다운로드합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
public async Task<IActionResult> ExportReportAsync([FromBody] StatsExportRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var fileBytes = await _statsService.ExportReportAsync(serviceId, request);
|
||||||
|
var fileName = $"stats_report_{request.StartDate}_{request.EndDate}.xlsx";
|
||||||
|
return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("failure")]
|
||||||
|
[SwaggerOperation(Summary = "실패원인 통계 조회", Description = "실패 원인별 집계를 상위 N개로 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FailureStatResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetFailureStatAsync([FromBody] FailureStatRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetFailureStatAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<FailureStatResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("dashboard")]
|
||||||
|
[SwaggerOperation(Summary = "대시보드 통합 조회", Description = "KPI, 일별 추이, 시간대별 분포, 플랫폼 비율, 상위 메시지를 한번에 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<DashboardResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetDashboardAsync([FromBody] DashboardRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetDashboardAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<DashboardResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("history/list")]
|
||||||
|
[SwaggerOperation(Summary = "이력 목록 조회", Description = "메시지별 발송 이력 목록을 조회합니다. keyword/status/date 필터를 지원합니다. X-Service-Code 헤더 미지정 시 전체 서비스 이력을 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<HistoryListResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetHistoryListAsync([FromBody] HistoryListRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetHistoryListAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<HistoryListResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("history/detail")]
|
||||||
|
[SwaggerOperation(Summary = "이력 상세 조회", Description = "특정 메시지의 발송 이력 상세(기본정보+집계+실패사유+본문)를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스에서 검색합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<HistoryDetailResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetHistoryDetailAsync([FromBody] HistoryDetailRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetHistoryDetailAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<HistoryDetailResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("history/export")]
|
||||||
|
[SwaggerOperation(Summary = "이력 엑셀 내보내기", Description = "이력 목록과 동일한 필터 기준으로 발송 이력을 엑셀(.xlsx)로 내보냅니다. X-Service-Code 헤더 미지정 시 전체 서비스 이력을 내보냅니다.")]
|
||||||
|
public async Task<IActionResult> ExportHistoryAsync([FromBody] HistoryExportRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var fileBytes = await _statsService.ExportHistoryAsync(serviceId, request);
|
||||||
|
var fileName = $"history_export_{DateTime.UtcNow:yyyyMMdd}.xlsx";
|
||||||
|
return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send-log")]
|
||||||
|
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SendLogDetailResponseDto>), 200)]
|
||||||
|
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetSendLogDetailAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<SendLogDetailResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long? GetOptionalServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var obj) && obj is long id)
|
||||||
|
return id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
SPMS.API/Controllers/TagController.cs
Normal file
82
SPMS.API/Controllers/TagController.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.Tag;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/tag")]
|
||||||
|
[Authorize]
|
||||||
|
[ApiExplorerSettings(GroupName = "tag")]
|
||||||
|
public class TagController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITagService _tagService;
|
||||||
|
|
||||||
|
public TagController(ITagService tagService)
|
||||||
|
{
|
||||||
|
_tagService = tagService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("list")]
|
||||||
|
[SwaggerOperation(Summary = "태그 목록 조회", Description = "태그 목록을 페이지 단위로 조회합니다. 서비스 필터, 키워드 검색, deviceCount를 포함합니다.")]
|
||||||
|
public async Task<IActionResult> GetListAsync([FromBody] TagListRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceIdOrNull();
|
||||||
|
var result = await _tagService.GetListAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<TagListResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("create")]
|
||||||
|
[SwaggerOperation(Summary = "태그 생성", Description = "새 태그를 생성합니다. 서비스당 최대 10개, 동일 서비스 내 태그명 중복 불가.")]
|
||||||
|
public async Task<IActionResult> CreateAsync([FromBody] CreateTagRequestDto request)
|
||||||
|
{
|
||||||
|
var adminId = GetAdminId();
|
||||||
|
var result = await _tagService.CreateAsync(adminId, request);
|
||||||
|
return Ok(ApiResponse<TagResponseDto>.Success(result, "태그 생성 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("update")]
|
||||||
|
[SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")]
|
||||||
|
public async Task<IActionResult> UpdateAsync([FromBody] UpdateTagRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
var result = await _tagService.UpdateAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<TagResponseDto>.Success(result, "태그 수정 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromBody] DeleteTagRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetServiceId();
|
||||||
|
await _tagService.DeleteAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long? GetServiceIdOrNull()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetServiceId()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
||||||
|
return serviceId;
|
||||||
|
throw new SpmsException(ErrorCodes.ServiceScopeRequired, "X-Service-Code 헤더가 필요합니다.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetAdminId()
|
||||||
|
{
|
||||||
|
var adminIdClaim = User.FindFirst("adminId")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
|
||||||
|
throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401);
|
||||||
|
|
||||||
|
return adminId;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
SPMS.API/Controllers/TermsController.cs
Normal file
36
SPMS.API/Controllers/TermsController.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using SPMS.Application.DTOs.AppConfig;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
|
||||||
|
namespace SPMS.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/in/public")]
|
||||||
|
[ApiExplorerSettings(GroupName = "public")]
|
||||||
|
public class TermsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAppConfigService _appConfigService;
|
||||||
|
|
||||||
|
public TermsController(IAppConfigService appConfigService)
|
||||||
|
{
|
||||||
|
_appConfigService = appConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("terms")]
|
||||||
|
[SwaggerOperation(Summary = "이용약관", Description = "이용약관 URL을 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetTermsAsync()
|
||||||
|
{
|
||||||
|
var result = await _appConfigService.GetTermsAsync();
|
||||||
|
return Ok(ApiResponse<AppConfigResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("privacy")]
|
||||||
|
[SwaggerOperation(Summary = "개인정보처리방침", Description = "개인정보처리방침 URL을 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetPrivacyAsync()
|
||||||
|
{
|
||||||
|
var result = await _appConfigService.GetPrivacyAsync();
|
||||||
|
return Ok(ApiResponse<AppConfigResponseDto>.Success(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 362 KiB |
|
|
@ -1,219 +0,0 @@
|
||||||
# 1. Branching Model (Git-Flow 방식)
|
|
||||||
|
|
||||||
- 각 브랜치는 사용하는 시점에서 생성을 하고 이슈가 해결(PR까지 완료) 된 경우 제거한다.
|
|
||||||
- 단, main, develop 브랜치는 **절대 삭제하지 않는다**.
|
|
||||||
|
|
||||||
## 1.1. 브랜치 명명 규칙
|
|
||||||
|
|
||||||
- 형식 : 타입`/#이슈번호-작업요약`
|
|
||||||
- 규칙
|
|
||||||
- `/` : 브랜치를 폴더 구조로 묶어준다.
|
|
||||||
- `-` : 단어 사이를 연결하며, 띄어쓰기는 금지한다.
|
|
||||||
- `#` : 이슈 추적을 위해 이슈 번호를 명시한다.
|
|
||||||
- **영문 소문자**만 사용한다.(한글 금지)
|
|
||||||
- 예시
|
|
||||||
- feature/#1-admin-login
|
|
||||||
- fix/#20-jwt-error
|
|
||||||
- 예외
|
|
||||||
- main, develop 브랜치의 명칭은 변하지 않는다.
|
|
||||||
- release 브랜치는 `release/v버전` 의 형식으로 작성한다.
|
|
||||||
|
|
||||||
## 1.2. 브랜치 전략표
|
|
||||||
|
|
||||||
| 타입 | 의미 | 브랜치 명 예시 | 생성위치 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| main | 배포 가능 최신 상용 상태 | main | - |
|
|
||||||
| develop | 다음 배포를 위한 통합 브랜치 | develop | - |
|
|
||||||
| feature | 새로운 기능 개발 | feature/#1-admin-login | develop |
|
|
||||||
| fix | 개발 중 버그 수정 | fix/#20-jwt-error | develop, release |
|
|
||||||
| refactor | 기능 변경 없는 코드 개선 | refactor/#13-folder-structure | develop |
|
|
||||||
| hotfix | [긴급] 운영 서버 버그 수정 | hotfix/#99-prd-db-error | main |
|
|
||||||
| release | 배포 버전 준비 | release/v1.0.0 | develop |
|
|
||||||
|
|
||||||
## 1.3. Branch Life Cycle
|
|
||||||
|
|
||||||
### 1.3.1. ***준비 과정***
|
|
||||||
|
|
||||||
- main / develop 브랜치 생성 및 초기화
|
|
||||||
|
|
||||||
### 1.3.2. ***개발 과정***
|
|
||||||
|
|
||||||
1. develop 에서 feature 브랜치 생성해 개발한다. (이슈 생성 필수)
|
|
||||||
2. 개발 완료 후 devlop으로 **커밋 > PR > 머지**를 수행한다.
|
|
||||||
3. 개발 중 발견된 버그는 develop에서 fix 브랜치를 생성하여 수정한다. (이슈 생성 필수)
|
|
||||||
4. 위 과정을 반복하다 배포 스펙이 완성되면 배포 준비 단계로 넘어간다.
|
|
||||||
|
|
||||||
### 1.3.3. ***배포 준비 과정***
|
|
||||||
|
|
||||||
1. develop 에서 `release/vX.X.X` 브랜치를 생성한다. (Code Freeze 상태)
|
|
||||||
- 이후 develop 에서는 다음 버전을 위한 개발을 진행해도 된다.
|
|
||||||
2. QA 중 발견된 버그는 release 브랜치에서 fix 브랜치를 생성해 release에 직접 커밋한다. (이슈 생성 필수)
|
|
||||||
3. 최종 테스트가 완료되면 배포 단계로 넘어간다.
|
|
||||||
|
|
||||||
### 1.3.4. ***배포 과정***
|
|
||||||
|
|
||||||
1. 정기 배포:
|
|
||||||
- release 브랜치를 main 에 머지하고 해당 버전에 대한 태그를 붙인다.
|
|
||||||
- 배포가 완료되면 release 브랜치를 develop 에 백머지(back-merge)하여 변경 사항을 동기화한다.
|
|
||||||
- 두 브랜치에 머지가 완료되면 release 브랜치는 **삭제**한다.
|
|
||||||
2. 긴급 배포
|
|
||||||
- main 에서 hotfix 브랜치를 생성하여 버그를 수정한다. (이슈 생성 필수)
|
|
||||||
- 검증 원칙
|
|
||||||
- hotfix 브랜치는 main 에 머지하기 전에 반드시 **로컬 테스트**와 **스테이징 검증**을 통과해야 한다.
|
|
||||||
- 검증과정에서 발생하는 수정 사항은 오직 hotfix 브랜치 내부에서만 커밋한다.
|
|
||||||
- 검증이 완료되기 전까지는 절대 main 브랜치에 PR을 머지하지 않는다.
|
|
||||||
- 배포 및 동기화
|
|
||||||
- 검증이 완료되면 main 에 머지하고 해당 버전에 대한 태그를 붙인다. (버전 patch 상승)
|
|
||||||
- 동시에 develop 에도 백머지(back-merge)를 수행하여 다음 버전 개발에 반영되게 변경 사항을 동기화한다.
|
|
||||||
- 모든 작업이 완료되면 hotfix 브랜치는 **삭제**한다.
|
|
||||||
|
|
||||||
# 2. Commit Message
|
|
||||||
|
|
||||||
- 형식: `타입: 설명 (이슈번호)`
|
|
||||||
- 타입: [2.1. Commit Type](https://www.notion.so/Git-Version-Control-2ed4d57b3bf0805cb29fff784579d427?pvs=21) 참고
|
|
||||||
- 설명: 언어 상관없이 편하게 내용 작성
|
|
||||||
- 이슈번호
|
|
||||||
- 일반: 해당 커밋이 어떠한 이슈의 참조인지 알려준다. (예: feat: 로그인 구현(#1))
|
|
||||||
- 동작: 해당 커밋이 연결된 이슈의 상황을 변경함 (PR 작성시 확인)
|
|
||||||
1. Close, Closes, Closed : 해당 커밋을 끝으로 이슈 닫기 (예: feat: 로그인 구현(Closes #1))
|
|
||||||
2. Fix, Fixes, Fixed : 해당 커밋을 끝으로 버그 수정되어 이슈 닫기 (예: fix: DB 예외 처리(Fixes #21))
|
|
||||||
3. Resolve, Resolves, Resolved : 무언가 해결이 되어 이슈 닫기 (예: feat: 푸시 구현(Resolves #13))
|
|
||||||
|
|
||||||
## 2.1. Commit Type
|
|
||||||
|
|
||||||
- 커밋의 메시지는 [Conventional Commits](https://www.conventionalcommits.org/ko/v1.0.0/) 의 규칙을 따른다.
|
|
||||||
|
|
||||||
> 커밋 메시지만 보고 소프트웨어의 버전을 올릴 수 있게 하자.
|
|
||||||
>
|
|
||||||
- 소프트웨어 버전은 `Major.Minor.Patch` 로 보통 세자리로 구성이 되는데 커밋 메시지만 보고도 세 자리 중 어디를 올려야 하는지에 대한 가이드이다. (초기 배포 시점의 계산이 아니라 배포 후 부터 계산한다.)
|
|
||||||
|
|
||||||
### **2.1.1. fix**
|
|
||||||
|
|
||||||
- 상황: 로직상의 오류, 오타, 크래시 등을 수정했으나 기능 명세는 변하지 않았다.
|
|
||||||
- 커밋: `fix: 화면 오타 수정 (Fixes #23)`
|
|
||||||
- 브랜치
|
|
||||||
- `hotfix` 브랜치: 이미 배포된 버전을 긴급 수정하는 것이므로 배포 시 `Patch 버전이 상승` 한다.
|
|
||||||
- `fix` 브랜치: 다음 버전을 출시 하기 위한 안정화 과정이므로 버전 숫자는 변하지 않는다.
|
|
||||||
- `feature` / `develop` 브랜치: 다음 버전을 출시 하기 위한 개발 과정이므로 버전 숫자는 변하지 않는다.
|
|
||||||
|
|
||||||
### **2.1.2. feat**
|
|
||||||
|
|
||||||
- 상황: 기획대로 새로운 기능이 코드에 추가되었다.
|
|
||||||
- 커밋: `feat: 외부 결제 추가 (Closes #34)`
|
|
||||||
- 브랜치
|
|
||||||
- `feature` 브랜치: 오직 이 브랜치에서만 사용한다. 당장 버전이 변하지는 않지만 이후 배포가 되면 `Minor 버전 상승` 의 근거가 된다.
|
|
||||||
|
|
||||||
### **2.1.3. BREAKING CHANGE**
|
|
||||||
|
|
||||||
- 상황: 이전 버전과 완전 호환되지 않는 기술적인 변경이 발생한다.
|
|
||||||
- 커밋: `feat!: 유저 API 전체 변경` (해당 타입의 뒤에 `!`를 붙인다.)
|
|
||||||
- 브랜치
|
|
||||||
- `feature` 브랜치: 다음 버전을 위한 대공사이므로 개발 단계에서 수행한다.
|
|
||||||
- 다른 브랜치: 선언 불가
|
|
||||||
- feature > develop > release > main 으로 머지 되면 `Major 버전이 상승` 한다.
|
|
||||||
- 해당 타입은 다른 타입에 붙어서 사용되므로 독자적으로 사용되지는 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **2.1.4.** test
|
|
||||||
|
|
||||||
- 상황
|
|
||||||
1. 새로운 테스트 코드 작성할 때
|
|
||||||
2. 기존 테스트 코드에 문제가 있어 테스트 코드만 수정할 때 (기능의 수정은 이루어지지 않는다.)
|
|
||||||
3. 테스트 데이터를 수정하거나 리팩토링 할 때
|
|
||||||
- 커밋: `test: 로그인 API 테스트`
|
|
||||||
- 브랜치
|
|
||||||
- 개발 수정 가능한 모든 브랜치 어디서든 사용 가능하다.
|
|
||||||
|
|
||||||
### **2.1.5.** chore
|
|
||||||
|
|
||||||
- 상황: 소스 코드는 건드리지 않고 빌드 설정, 패키지 업데이트, 라이브러리 설치 등을 수정했다.
|
|
||||||
- 커밋: `chore: JSON 패키지 업데이트`
|
|
||||||
- 브랜치
|
|
||||||
- 개발 수정 가능한 모든 브랜치 어디서든 사용 가능하다.
|
|
||||||
|
|
||||||
### **2.1.6.** refactor
|
|
||||||
|
|
||||||
- 상황: 기능과 결과는 100% 동일하지만 내부 코드 구조를 개선하거나 성능을 최적화했다.
|
|
||||||
- 커밋: `refactor: 로그인 화면 개편`
|
|
||||||
- 브랜치
|
|
||||||
- `feature` / `refactor` 브랜치: 주로 개발 단계에서 사용한다.
|
|
||||||
- `fix` 브랜치: 사용은 가능하나 브랜치의 역할이 다르니 최소한으로 사용한다.
|
|
||||||
- 다른 브랜치: 선언 불가
|
|
||||||
|
|
||||||
### **2.1.7.** style
|
|
||||||
|
|
||||||
- 상황: 코드의 동작과 전혀 상관없는 띄어쓰기, 줄 바꿈, 들여쓰기 등을 수정했다.
|
|
||||||
- 커밋: `style: 불필요한 공백 제거`
|
|
||||||
- 브랜치
|
|
||||||
- 개발 수정 가능한 모든 브랜치 어디서든 사용 가능하다.
|
|
||||||
- 표
|
|
||||||
|
|
||||||
| **브랜치 (Branch)** | **feat** | **fix** | **BREAKINGCHANGE** | **test / chore / style** | **refactor** | **비고 (Note)** |
|
|
||||||
|:-----------------:|:-----------------:|:-----------------:|:------------------:|:------------------------:|:-----------------:|:----------------------------------------|
|
|
||||||
| **main** | ❌ | ❌ | ❌ | ❌ | ❌ | **Merge Only**<br>(직접 커밋 금지) |
|
|
||||||
| **develop** | ❌ | ❌ | ❌ | ❌ | ❌ | **Merge Only**<br>(직접 커밋 금지) |
|
|
||||||
| **feature/...** | **⭕**<br>**(주력)** | ⭕<br>(자체수정) | **⭕**<br>**(가능)** | ⭕ | ⭕ | 모든 작업이 가능한 **자유 구역** |
|
|
||||||
| **fix/...** | ❌ | **⭕**<br>**(주력)** | ❌ | ⭕ | 🔺<br>(최소한) | 기능 추가 절대 금지<br>테스트 보강 가능 |
|
|
||||||
| **hotfix/...** | ❌ | **⭕**<br>**(주력)** | ❌ | ⭕ | ❌ | **[긴급]** 긴급 수정 및<br>검증용 테스트만 허용 |
|
|
||||||
| **release/...** | ❌ | **⭕**<br>**(주력)** | ❌ | ⭕ | ❌ | **[배포전]** 안정화 및<br>검증용 테스트만 허용 |
|
|
||||||
| **refactor/...** | ❌ | ❌ | ❌ | ⭕ | **⭕**<br>**(주력)** | 동작 변경 금지 (fix 불가)<br>기능 추가 금지 (feat 불가) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3. Pull Request(PR) Message
|
|
||||||
|
|
||||||
- 프로젝트 최상단 폴더에 `.gitea/pull_request_template.md` 혹은 `.github/` 폴더에 저장하면 PR 생성시 자동으로 내용이 채워진다.
|
|
||||||
- 타이틀
|
|
||||||
- PR 은 결국 main 이나 develop 브랜치의 커밋이 되기에 타이틀은 [2. Commit Message](https://www.notion.so/Git-Version-Control-2ed4d57b3bf0805cb29fff784579d427?pvs=21)를 따라서 작성한다.
|
|
||||||
- 형식: `타입: 작업한 커밋들 요약 설명 (이슈번호)`
|
|
||||||
- 본문
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 📋 작업 요약
|
|
||||||
- {작업 3줄 요약}
|
|
||||||
|
|
||||||
## 🔗 관련 이슈 (Related Issues)
|
|
||||||
Closes #
|
|
||||||
|
|
||||||
## 🛠️ 작업 내용 (Changes)
|
|
||||||
- [ ] {작업 내용 1}
|
|
||||||
- [ ] {작업 내용 2}
|
|
||||||
|
|
||||||
## 📢 리뷰어 참고 사항 (To Reviewers)
|
|
||||||
- 없을시 비워둠
|
|
||||||
- {리뷰어 참고 사항}
|
|
||||||
|
|
||||||
## ✅ 체크리스트 (Self Checklist)
|
|
||||||
- [ ] 빌드(Build)가 성공적으로 수행되었는가?
|
|
||||||
- [ ] 모든 단위 테스트(Unit Test)를 통과하였는가?
|
|
||||||
- [ ] 불필요한 로그나 주석을 제거하였는가?
|
|
||||||
- [ ] 컨벤션(Clean Architecture, Naming)을 준수하였는가?
|
|
||||||
- [ ] 기밀 정보(비밀번호, 키 등)가 하드코딩 되어있지 않은가?
|
|
||||||
|
|
||||||
## 📸 스크린샷 / 테스트 로그 (Screenshots/Logs)
|
|
||||||
- 없을시 비워둠
|
|
||||||
```
|
|
||||||
// 테스트 로그 작성
|
|
||||||
```
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 4. 태그(Tag)를 붙이는 2가지 타이밍
|
|
||||||
|
|
||||||
- 태그(Tag)는 개발자가 코드에 찍는 **최종 승인 도장**으로, 아무때나 찍는 게 아니라 사용자에게 배포되는 버전 번호가 확정되는 순간에만 작성한다.
|
|
||||||
- 태그는 오직 main 브랜치에 코드가 머지된 직후에만 붙이는 것을 원칙으로 한다.
|
|
||||||
|
|
||||||
## 4.1. 정기 배포
|
|
||||||
|
|
||||||
- 상황: release/v1.0.0 브랜치에서 모든 QA를 끝내고 main 브랜치에 PR 후 merge 진행
|
|
||||||
- 버전: v1.0.0 → v1.1.0
|
|
||||||
- 태그: v1.1.0
|
|
||||||
|
|
||||||
## 4.2. 긴급 배포
|
|
||||||
|
|
||||||
- 상황: hotfix/… 브랜치에서 main에서 발생한 급한 오류를 해결하고 main 브랜치에 PR 후 merge 진행
|
|
||||||
- 버전: v1.1.0 → v1.1.1
|
|
||||||
- 태그: v1.1.1
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
# 1. 기술 스택 및 환경
|
|
||||||
| 기술 | 스택 |
|
|
||||||
|:----------|:------------------------------------|
|
|
||||||
| Framework | .NET 9.0 / ASP .NET Core |
|
|
||||||
| Database | MariaDB (Server Version AutoDetect) |
|
|
||||||
| ORM | Entity Framework Core (Code-First) |
|
|
||||||
| API Docs | Swagger (OpenAPI) |
|
|
||||||
| Cache | Redis |
|
|
||||||
| MQ | RabbitMQ |
|
|
||||||
| Logging | Serilog |
|
|
||||||
| Testing | xUnit, Moq |
|
|
||||||
|
|
||||||
---
|
|
||||||
# 2. 코드 컨벤션
|
|
||||||
## 2.1. 명명 규칙
|
|
||||||
- PascalCase (대문자 시작): 클래스명, 메서드명, 프로퍼티명, 파일명, public 필드
|
|
||||||
- camelCase (소문자 시작): 로컬 변수, 매개 변수
|
|
||||||
- _camelCase (언더바 시작): private 필드
|
|
||||||
- Interface 는 반드시 접두사 `I` 를 붙인다.
|
|
||||||
- 비동기 메서드는 접미사 `Async` 를 붙인다.
|
|
||||||
|
|
||||||
## 2.2. 문법 규칙
|
|
||||||
|
|
||||||
- `var` 사용: 자료형을 명확하게 우변에서 확인 가능할 경우에는 var를 적극 사용한다.
|
|
||||||
- 리턴 타입이 뭔지 모르는 경우에는 명시를 해준다.
|
|
||||||
```csharp
|
|
||||||
var user = new User(); // 가능
|
|
||||||
|
|
||||||
var count = GetCount(); // 불가능
|
|
||||||
int count = GetCount(); // 리턴 타입을 명시해줄 것
|
|
||||||
```
|
|
||||||
- 비동기(Asnyc/Await) 동작: I/O 작업(DB, API호출, 파일)은 **무조건 비동기**로 작성한다.
|
|
||||||
- 비동기 동작을 하는 메서드의 이름 뒤에는 `Asnyc` 를 붙인다.
|
|
||||||
- void 대신 `Task` 또는 `Task<T>`를 반환한다.
|
|
||||||
- LINQ: 가독성을 해치지 않는 선에서 적극 사용하되, 복잡한 쿼리는 쿼리 구문이나 분할 작성 할 것
|
|
||||||
- 주석 및 문서화 (Swagger): API 정의서와의 동기화를 위해 Controller와 DTO의 모든 Public 멤버에는 반드시 XML 주석(`/// <summary>`)을 작성해야 한다.
|
|
||||||
|
|
||||||
-
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3. 아키텍쳐 및 개발 표준
|
|
||||||
|
|
||||||
## 3.1. 개요
|
|
||||||
|
|
||||||
- 의존성의 방향은 항상 외부에서 내부로만 향한다.
|
|
||||||
- 프로젝트 분리: 프로젝트는 클린 아키텍쳐를 기반으로 하여 물리적으로 4개의 .csproj 로 분리해 참조 규칙을 **강제**한다.
|
|
||||||
<br><br>
|
|
||||||
- 클린 아키텍쳐 샘플 이미지
|
|
||||||

|
|
||||||
|
|
||||||
## 3.2. 계층 구조 (Layers)
|
|
||||||
- 계층의 정의는 내부에서 외부 순서로 정의한다.
|
|
||||||
|
|
||||||
### 3.2.1. 계층 (Layers)
|
|
||||||
|
|
||||||
#### 1. Domain (Entities, Enterprise Business Rules)
|
|
||||||
- 역할: 비즈니스의 핵심 개념, 기업 업무 규칙(Entity)을 정의한다.
|
|
||||||
- 특징: 외부 라이브러리 (DB, Web, 등)에 대한 의존성이 **없어야** 한다. 순수한 C# 클래스로만 구성된다.
|
|
||||||
- 요소: Entites, Value Objects, Enums, Domain Exceptions, Repository Interfaces (선택)
|
|
||||||
#### 2. Application (Use cases, Application Business Rules)
|
|
||||||
- 역할: 애플리케이션의 비즈니스 로직을 처리하고 도메인과 외부를 연결하는 오케스트레이션 담당한다.
|
|
||||||
- 특징: 도메인 계층에만 의존하며, 인터페이스를 정의하고 구현은 외부 계층(Infra Structure)에 맡긴다.
|
|
||||||
- 요소: Service Interfaces, DTOs, Mappers, Validators, Service, Implementations (pure 로직)
|
|
||||||
#### 3. Infra Structure (Interface Adapters)
|
|
||||||
- 역할: 애플리케이션 계층에서 정의한 인터페이스를 실제로 구현한다. DB, 외부 API, 파일 시스템 등 외부 세계와 통신한다.
|
|
||||||
- 특징: 애플리케이션과 도메인을 참조하고 다양한 라이브러리를 사용한다.
|
|
||||||
- 요소: Repository 구현체, DB Context, 외부 API Client, Migrations
|
|
||||||
#### 4. Presentation (Frameworks & Drivers)
|
|
||||||
- 역할: 사용자의 요청(HTTP)을 받아 애플리케이션 계층으로 전달하고 결과를 반환한다.
|
|
||||||
- 특징: 로직을 가지지 않으며 애플리케이션에 의존한다.
|
|
||||||
- 요소: Controller, Middlewares, Filters, Program.cs (DI 설정)
|
|
||||||
|
|
||||||
### 3.2.2. 프로젝트 참조 규칙
|
|
||||||
- 프로젝트 내에서 참조 설정을 시스템 상에서 해줘서 파일들 간의 물리적인 연결 고리를 룰에 맞게 설정해 줘야 한다.
|
|
||||||
- Domain: 참조 없음
|
|
||||||
- Application: Domain
|
|
||||||
- Infra Structure: Application, Domain
|
|
||||||
- Presentation: Application, Infra Structure
|
|
||||||
|
|
||||||
## 3.3. 개발 상세 가이드
|
|
||||||
|
|
||||||
### 3.3.1. API 프로토콜 및 데이터 규격
|
|
||||||
- 클라이언트와 서버 간의 모든 통신은 아래 정의된 Header와 Body 규격을 준수해야 한다.
|
|
||||||
#### 1. 요청 규격 (Request)
|
|
||||||
- 클라이언트는 API 호출 시, HTTP Header에 다음 필수 메타데이터를 포함해야 한다.
|
|
||||||
- 메타데이터들은 로그 컨텍스트 식별을 위해 평문으로 전송한다.
|
|
||||||
|
|
||||||
| 헤더 명 | 설명 및 용도 | 예시 |
|
|
||||||
| --- |-------------------------------------| --- |
|
|
||||||
| Content-Type | 전송 데이터 포맷 | application/octet-stream |
|
|
||||||
| Authorization | JWT 인증 토큰<br>- L2 등급 이상 필수 | Bearer abcdefg… |
|
|
||||||
| X-API-KEY | 프로젝트 식별 및 데이터 격리를 위한 고유 키 (테넌트 식별자) | spms_api_custom_key |
|
|
||||||
| X-Request-ID | 트랜잭션 추적 ID<br>- 매요청 마다 고유값 생성<br>- 재사용 금지 | abcdefg-12aa… |
|
|
||||||
|
|
||||||
#### 2. 응답 규격 (Response)
|
|
||||||
- 서버는 비즈니스 로직의 결과와 상관 없이 항상 아래의 공통 JSON포맷으로 응답한다.
|
|
||||||
- 단, Body의 내용물인 data 필드는 보안 등급에 따라 암호화될 수 있다.
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"req_id": "abcdefg-12aa…", "result": "success", "code": "0000", "msg": "Response Success MESSAGE", "data": { ... } }
|
|
||||||
``` - req_id: 요청 헤더의 X-Request-ID를 그대로 반환 (없을 경우 서버 생성), 비동기 응답 식별용
|
|
||||||
- result: API 호출 결과에 따라 성공(success) / 실패(fail) 여부 반환
|
|
||||||
- code: API 명세서에 선언된 코드 값 반환 (성공시 0000)
|
|
||||||
- msg: 현재 동작에 대한 결과 메시지 반환
|
|
||||||
- data: 현재 동작에 대한 데이터 반환 (E2EE 적용시 암호화된 문자열)
|
|
||||||
|
|
||||||
#### 3. 데이터 매핑 원칙
|
|
||||||
- Entity 노출 금지 (Layer: Domain)
|
|
||||||
- DB 테이블과 매핑되거나 핵심 로직을 가진 객체로, **절대** Controller 밖으로 노출하지 않는다.
|
|
||||||
- DTO 필수 (Layer: Application): 데이터 전송을 위한 껍데기 객체
|
|
||||||
- Controller는 반드시 `Request DTO` 를 받고 `Response DTO` 를 반환해야 한다.
|
|
||||||
- API 스펙 변경이 도메인 모델에 영향을 주어서는 안되며 그 반대의 경우도 마찬가지이다.
|
|
||||||
- Body 암호화 정책
|
|
||||||
- [3.3.8. 보안 정책](#338-데이터-보안-및-암호화)에 의거 **L3**등급 이상의 data 필드는 암호화된다.
|
|
||||||
|
|
||||||
### 3.3.2. 비즈니스 로직과 의존성 주입 (DI)
|
|
||||||
#### 1. 비즈니스 로직 위치
|
|
||||||
- 단순 데이터 조회 / 저장
|
|
||||||
- Repository (Layer: Infra Structure)에서 담당하되, 내부 구현 로직이 없어야 한다.
|
|
||||||
- 업무 규칙 (유효성 검사, 계산, 흐름 제어)
|
|
||||||
- 반드시 Service (Layer: Application) 또는 Entity (Layer: Domain) 내부에 위치해야 한다.
|
|
||||||
- Controller
|
|
||||||
- 요청을 받고 Service를 호출하고 결과를 DTO로 변환하는 역할만 수행한다.
|
|
||||||
#### 2. 의존성 역전 원칙 (DIP)
|
|
||||||
- Interface 정의(Layer: Application)
|
|
||||||
- 특정한 동작에 대한 정의서를 Application 계층에서 선언한다.
|
|
||||||
- 예: 유저 정보를 저장하는 기능의 필요 = IUserRepository
|
|
||||||
- 구현(Layer: Infra Structure)
|
|
||||||
- 해당 동작의 특별한 동작 구현을 Infra Structure 계층에서 구현한다.
|
|
||||||
- 예: 저장 기능을 EF Core로 구현 = UserRepository
|
|
||||||
- 주입 (Layer: Presentation)
|
|
||||||
- Program.cs 에서 DI를 통해 둘을 연결해준다.
|
|
||||||
- 예: builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|
||||||
|
|
||||||
### 3.3.3. 데이터베이스 접근 (EF Core)
|
|
||||||
1. Code-First
|
|
||||||
- C# 엔티티 코드가 메인이 되며 Migration 명령어로 DB를 업데이트 한다.
|
|
||||||
2. Fluent API 사용
|
|
||||||
- Entity 클래스의 순수성을 위해 [Key], [Table] 과 같은 어트리뷰트 대신 DbContext 내 OnModelCreating 또는 별도의 IEntityTypeConfiguration 파일에서 설정한다.
|
|
||||||
3. Production 배포 전략
|
|
||||||
- 개발/스테이징 환경에서는 자동 마이그레이션을 허용하나, 운영(Production) 환경에서는 절대 Database.Migrate() 자동 실행을 금지한다.
|
|
||||||
4. Script Migration
|
|
||||||
- 반드시 dotnet ef migrations script 명령어로 SQL 스크립트를 생성하여, DBA 또는 관리자의 검수를 거친 후 수동/CI 파이프라인을 통해 적용한다.
|
|
||||||
|
|
||||||
### 3.3.4. 유효성 관리
|
|
||||||
|
|
||||||
- FluentValidation 사용: 잦은 if문 대신 `AbstractValidator<T>` 를 상속 받은 별도 클래스로 분리한다.
|
|
||||||
- 동작: Controller 진입 전 또는 Service 실행 시점에 자동 검증한다.
|
|
||||||
- 위치: SPMS.Application/Validators
|
|
||||||
|
|
||||||
### 3.3.5. 트랜잭션 관리
|
|
||||||
|
|
||||||
- 서비스 단위: 하나의 Service 메서드가 하나의 트랜잭션 단위가 된다.
|
|
||||||
- 원자성 (Atomicity): 원자적인 작업 단위로 모든 작업이 성공적으로 완료되지 않는다면, 즉 하나라도 실패하면 전체 롤백한다.
|
|
||||||
|
|
||||||
### 3.3.6. 예외 처리
|
|
||||||
|
|
||||||
- Global Handling: 개별 메서드에서 try-catch로 에러를 삼키지 않는다.
|
|
||||||
- Custom Exception: 비즈니스 로직 에러는 의도적으로 `throw new BusinessExeption(”msg”)`을 발생시킨다
|
|
||||||
- Middleware: 전역 미들웨어에서 에러를 잡아 공통 포맷으로 변환하여 응답한다.
|
|
||||||
|
|
||||||
### 3.3.7. 인증 및 접근 제어
|
|
||||||
|
|
||||||
- 시스템 접근 권한은 ‘Who, Where, How often’ 의 3중 체계로 검증한다.
|
|
||||||
#### 1. 인증 - JWT
|
|
||||||
- Stateless 구조를 위해 JWT 방식을 표준으로 한다.
|
|
||||||
- 구현
|
|
||||||
- Authorization 헤더에 Bearer Token 을 담아 전송하며 만료 시 Refresh Token 으로 갱신한다.
|
|
||||||
#### 2. 인가 - 역할 기반 엑세스 제어 (RBAC)
|
|
||||||
- 사용자의 Role(Admin, Manager, User) 에 따라 API 접근 권한을 엄격히 분리한다.
|
|
||||||
- 구현
|
|
||||||
- Controller 상단에 `[Authorize(Roles="...")]` 어트리뷰트를 명시해 코드 레벨에서 권한을 강제한다.
|
|
||||||
#### 3. 네트워크 접근 제어
|
|
||||||
- API Key가 탈취되더라도 허용되지 않은 IP에서의 접근을 원천 차단한다.
|
|
||||||
- 구현
|
|
||||||
- 모든 API요청 시 미들웨어에서 클라이언트 IP를 추출한다.
|
|
||||||
- ServiceIP 테이블에 등록된 IP 대역인지 검증하고 불일치 시 403 Forbidden 처리한다.
|
|
||||||
#### 4. API 속도 제한
|
|
||||||
- Dos 공격 및 루프로 인한 과부하 방지
|
|
||||||
- AspNetCoreRateLimit 을 사용하여 IP 주소 또는 API Key 별로 초당/분당 요청 쿼터를 제한한다.(초과시 429 Too Many Requests)
|
|
||||||
|
|
||||||
### 3.3.8. 데이터 보안 및 암호화
|
|
||||||
- 데이터의 생명 주기 (저장, 전송) 전반에 걸쳐 데이터 등급에 따른 암호화 정책을 적용한다.
|
|
||||||
|
|
||||||
#### 1. 데이터 등급 분류
|
|
||||||
|
|
||||||
| 등급 | 암호화 방식 | 대상 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| L1 (Public) | 평문 | 공지사항, 일반 리소스 |
|
|
||||||
| L2 (Authenticated) | HTTPS + JWT | 일반 서비스 데이터 |
|
|
||||||
| L3 (Sensitive) | HTTPS + E2EE + AES-256 저장 | API Key, 토큰 |
|
|
||||||
| L4 (Critical) | HTTPS + E2EE + BCrypt 저장 | 비밀번호 |
|
|
||||||
|
|
||||||
#### 2. 암호화 - 저장
|
|
||||||
- 양방향 암호화: 외부 연동에 필요한 민감 정보는 AES-256 (CBC/GCM) 알고리즘으로 암호화해 DB에 저장한다.
|
|
||||||
- 단방향 해시: 비밀번호 등 복호화가 불필요한 정보는 BCrypt 알고리즘(Salt 포함)을 사용해 해싱 저장한다. (절대 평문 저장 금지)
|
|
||||||
- Key 관리: 암호화 키는 소스 코드가 아닌 환경 변수 또는 Key Vault로 관리한다.
|
|
||||||
|
|
||||||
#### 3. 암호화 - 전송
|
|
||||||
- 적용 대상 : L3, L4 등급의 데이터 전송 시 필수 적용
|
|
||||||
- 알고리즘: AES-256 (데이터 암호화) + RSA-2048 (키 교환) 하이브리드 방식
|
|
||||||
- 구현 프로세스
|
|
||||||
- **[요청 시: Client → Server]**
|
|
||||||
1. 암호화 값 생성: Client 는 요청마다 새로운 일회용 대칭키(AES Key) 와 IV 를 생성한다.
|
|
||||||
2. 데이터 암호화: 요청 본문을 생성한 Key와 IV로 암호화 한다.
|
|
||||||
3. 키 암호화: 생성한 AES Key를 서버의 공개키로 암호화한다. (RSA)
|
|
||||||
4. 패킹 구조 (Parsing 효율성을 위해 고정 길이 필드 우선 배치)
|
|
||||||
- JSON 포맷 사용 금지: 페이로드의 효율성과 구조 은닉을 위해 바이트 배열로 직렬화하여 전송한다.
|
|
||||||
- 구조: `[ Encrypted AES Key (256 Bytes) ] + [ IV (16 Bytes) ] + [ Encrypted Body (Variable) ]`
|
|
||||||
- Body가 없는 단순 조회 요청이라도 Key와 IV는 **필수 전송**한다.(Body = 0 Byte)
|
|
||||||
- 파싱 전략
|
|
||||||
- Server는 수신된 바이너리 스트림의 첫 256바이트를 잘라 RSA 복호화하여 AES Key 획득
|
|
||||||
- 다음 16바이트를 잘라 IV 획득
|
|
||||||
- 나머지 바이트를 AES 복호화해 원본 Body 획득
|
|
||||||
- **[응답 시: Server → Client]**
|
|
||||||
1. 키 재사용 : 응답 시에는 키 교환을 하지 않고, 요청 패킷에서 획득한 AES Key를 그대로 재사용한다.
|
|
||||||
2. 새로운 IV 생성: 보안 강화를 위해 응답을 위한 새로운 IV를 새로 생성한다.
|
|
||||||
3. 패킹 구조 (Parsing 효율성을 위해 고정 길이 필드 우선 배치)
|
|
||||||
- 구조: `[ IV (16 Bytes) ] + [ Encrypted Body (Variable) ]`
|
|
||||||
- Client 는 자신이 보냈던 Key와 응답에 포함된 IV 를 사용해 복호화 한다.
|
|
||||||
4. 복호화: Client는 자신이 갖고 있던 AES Key와 응답 패킷의 IV를 사용해 본문을 복호화한다.
|
|
||||||
|
|
||||||
### 3.3.9. 보안 감사 및 시큐어 코딩
|
|
||||||
- 이미 정의된 네트워크/암호화 보안 외에 Application 계층에서 발생할 수 있는 취약점을 방어하고 추적성을 확보한다.
|
|
||||||
#### 1. 보안 감사 로그
|
|
||||||
- 저장소: SystemLog 테이블 (비동기 저장)
|
|
||||||
- 기록 대상
|
|
||||||
- 인증 실패: 로그인 n회 연속 실패, 비인가 프로젝트 키 접근 시도
|
|
||||||
- 권한 위반: 401 Unauthorized, 403 Forbidden 응답 발생한 모든 요청
|
|
||||||
- 중요한 변경: 관리자에 의한 데이터 변경, 데이터 삭제 행위
|
|
||||||
- 로그 마스킹
|
|
||||||
- 헤더 및 개인정보는 로그 저장 시 반드시 마스킹 처리를 한다.
|
|
||||||
- 비밀번호, 암호화 키 원문은 어떠한 경우에도 로그 파일이나 콘솔에 평문으로 출력하지 않는다.
|
|
||||||
#### 2. 시큐어 코딩
|
|
||||||
- SQL Injection 방지: Raw Query 사용을 금지하며 반드시 EF Core(ORM) 메서드나 Parameterized Query 만 사용한다.
|
|
||||||
- XSS(Cross-Site Scripting) 방지
|
|
||||||
- BO 에서 HTML 입력을 허용해야 하는 경우 `<script>`, `<iframe>` 등 위험 태그를 제거 후 저장한다. (라이브러리: HtmlSanitizer)
|
|
||||||
- 그 외 모든 입/출력 데이터는 기본적으로 HTML Encode 처리한다.
|
|
||||||
|
|
||||||
### 3.3.10. 웹 보안 및 헤더 정책
|
|
||||||
- [3.3.1의 요청 규격](#331-api-프로토콜-및-데이터-규격)과 달리 서버가 BO 등 브라우저 클라이언트 보호를 위해 응답시 자동으로 주입하는 보안 헤더 정책이다.
|
|
||||||
- 미들웨어에서 일괄 처리해서 진행한다.
|
|
||||||
1. HTTPS 강제 (HSTS)
|
|
||||||
- 중간자 공격 방지 및 프로토콜 보안 강화
|
|
||||||
- 설정: Strict-Transport-Security: max-age=31536000; includeSubDomains
|
|
||||||
2. CORS
|
|
||||||
- `AllowAnyOrigin(*)` 설정을 **절대 금지**한다.
|
|
||||||
- 설정: 화이트리스트에 등록된 도메인(Admin URL, Partner URL)의 요청에 대해서만 허용 응답을 반환한다.
|
|
||||||
3. 보안 헤더 자동 주입
|
|
||||||
- 모든 API 응답에 아래 헤더를 강제로 포함시켜 브라우저의 보안 기능을 활성화한다.
|
|
||||||
|
|
||||||
| 헤더 명 | 방어 목적 | 설정 값 |
|
|
||||||
| ----------------------- | -------------------------------------- | ------------------- |
|
|
||||||
| X-Frame-Options | 클릭재킹(Clickjacking) 및 `<iframe>` 임베딩 차단 | DENY |
|
|
||||||
| X-XSS-Protection | 브라우저 내장 XSS 필터 강제 활성화 | 1; mode=block |
|
|
||||||
| X-Content-Type-Options | MIME 타입 스니핑 차단 (악성 파일 실행 방지) | nosniff |
|
|
||||||
| Content-Security-Policy | 승인된 소스 외의 스크립트/리소스 로딩 차단 | default-src 'self'; |
|
|
||||||
|
|
||||||
### 3.3.11. 핵심 기술 구현 전략
|
|
||||||
|
|
||||||
- 주요 난제인 대량 트래픽 처리와 멀티 테넌시 격리를 위해 아래 4가지를 강제한다.
|
|
||||||
#### 1. 멀티 테넌시 격리
|
|
||||||
- Context Caching
|
|
||||||
- 미들웨어는 X-API-KEY 검증 시 매번 DB를 조회하지 않고, Redis 캐시를 우선 조회하여 ProjectID를 식별한다.
|
|
||||||
- Global Query Filter
|
|
||||||
- 식별된 ProjectID를 EF Core의 Global Query Filter에 주입하여, 개발자의 실수로 인한 타 프로젝트 데이터 조회를 원천 차단한다.
|
|
||||||
#### 2. 멱등성 보장
|
|
||||||
- 중복 발송 방지
|
|
||||||
- 네트워크 타임아웃으로 인한 재시도로 중복 실행을 막기 위해, 요청 진입 시 Redis에 X-Request-ID를 키로 조회한다.
|
|
||||||
- Atomic Check
|
|
||||||
- 이미 처리 중이거나 완료된 ID라면 작업을 즉시 폐기(Discard)한다
|
|
||||||
#### 3. 대량 처리 최적화
|
|
||||||
- Bulk Insert
|
|
||||||
- 대량의 데이터 저장시 단건 Loop Insert를 금지하며, EF Core Bulk Extensions 를 사용하여 단일 커넥션으로 처리한다.
|
|
||||||
- MQ QoS (Rabbit MQ)
|
|
||||||
- Worker 서비스는 RabbitMQ의 Prefetch Count를 적정값으로 설정하여 메모리 과부하 없이 안정적으로 메시지를 소비해야 한다.
|
|
||||||
#### 4. 데드 토큰 자동 정리
|
|
||||||
- Worker 서비스가 발송 결과를 처리할 때 APNs/FCM 으로부터 Unregistered(앱 삭제) 또는 BadDeviceToken 응답을 받으면 즉시 해당 토큰을 DB에서 물리 삭제하여 비용 낭비를 방지한다.
|
|
||||||
|
|
||||||
### 3.3.12. API 버전 관리 (Versioning Strategy)
|
|
||||||
|
|
||||||
- URI Versioning: 모든 API 엔드포인트는 URI 경로에 버전을 명시하여, 클라이언트의 강제 업데이트 없이도 구버전을 지원해야 한다.
|
|
||||||
- 규칙: `GET /api/v1/users` (메이저 버전만 명시)
|
|
||||||
- 정책: Breaking Change(필드 삭제, 타입 변경 등)가 발생할 경우에만 v2로 버전을 올리며, 기존 v1은 최소 N개월간 유지(Deprecated) 후 종료한다.
|
|
||||||
|
|
||||||
### 3.3.13. 시스템 회복 탄력성 (Resilience & Fault Tolerance)
|
|
||||||
- 외부 시스템(DB, Redis, FCM, APNs) 장애가 전체 시스템 중단으로 전파되는 것을 막기 위해 Polly 라이브러리를 사용하여 아래 정책을 적용한다.
|
|
||||||
- Retry (재시도): 일시적인 네트워크 오류 발생 시, Exponential Backoff(지수 대기: 2초, 4초, 8초...) 방식으로 최대 3회 재시도한다.
|
|
||||||
- Circuit Breaker (차단기): 특정 외부 시스템(예: FCM)이 연속 N회 실패 시, 즉시 회로를 차단(Open)하여 대기 시간 없이 Fail-Fast 처리하고 시스템 리소스를 보호한다.
|
|
||||||
(일정 시간 후 재접속 시도)
|
|
||||||
- Timeout: 모든 외부 호출에는 반드시 제한 시간(Timeout)을 설정한다.
|
|
||||||
|
|
||||||
### 3.3.14. 상태 모니터링 (Health Checks)
|
|
||||||
- 로드밸런서 및 모니터링 시스템이 서버의 생존 여부를 판단할 수 있도록 표준 엔드포인트를 제공한다.
|
|
||||||
- Endpoint: `GET /health`
|
|
||||||
- 검사 항목
|
|
||||||
- Liveness: 서버 프로세스가 떠 있는가? (단순 Ping)
|
|
||||||
- Readiness: DB, Redis, RabbitMQ와 정상적으로 연결되어 트래픽을 받을 준비가 되었는가?
|
|
||||||
- 응답: 모든 연결 정상 시 200 OK, 하나라도 실패 시 503 Service Unavailable 반환.
|
|
||||||
|
|
||||||
## 3.4. 폴더 및 네임스페이스 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
📂 SPMS.Solution
|
|
||||||
├── 📂 SPMS.Domain
|
|
||||||
│ ├── Entities (User.cs, Project.cs)
|
|
||||||
│ ├── Enums (UserRole.cs)
|
|
||||||
│ └── Exceptions (UserNotFoundException.cs)
|
|
||||||
│
|
|
||||||
├── 📂 SPMS.Application
|
|
||||||
│ ├── Interfaces (IUserService.cs, IUserRepository.cs)
|
|
||||||
│ ├── DTOs (Req, Res)
|
|
||||||
│ ├── Services (UserService.cs - 인터페이스 구현 아님, 로직 담당)
|
|
||||||
│ └── Mappers (MappingProfile.cs)
|
|
||||||
│
|
|
||||||
├── 📂 SPMS.Infrastructure
|
|
||||||
│ ├── Persistence (AppDbContext.cs)
|
|
||||||
│ ├── Repositories (UserRepository.cs - IUserRepository 구현)
|
|
||||||
│ └── ExternalServices (EmailService.cs)
|
|
||||||
│
|
|
||||||
└── 📂 SPMS.API (ASP.NET Core Web API)
|
|
||||||
├── Controllers (UsersController.cs)
|
|
||||||
├── Middlewares
|
|
||||||
└── Program.cs
|
|
||||||
```
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# 1. 협업 & 작업 프로세스 (Workflow)
|
|
||||||
|
|
||||||
## 1.1. Tool Chain
|
|
||||||
|
|
||||||
- 문서화 (Documentation)
|
|
||||||
- Google Documents : [구글 드라이브 링크](https://drive.google.com/drive/folders/1m-2W7BcwHgKuFFNEr9Pblgpce3NEB_TV?usp=drive_link)
|
|
||||||
- Notion : [노션 SPMS 홈 링크](https://www.notion.so/2eb4d57b3bf08194b974d3384a639860?pvs=21)
|
|
||||||
- 이슈 관리 (Issue Tracking)
|
|
||||||
- YouTrack : [YouTrack SPMS 프로젝트 링크](https://ipstein.youtrack.cloud/projects/)
|
|
||||||
- 코드 관리 (Code Repository)
|
|
||||||
- Gitea : [Gitea SPMS 프로젝트 링크](https://git.ipstein.myds.me/SPMS)
|
|
||||||
- Commnication
|
|
||||||
- Discord
|
|
||||||
- Slack
|
|
||||||
|
|
||||||
## 1.2. Task LifeCycle
|
|
||||||
|
|
||||||
- 모든 개발 작업은 YouTrack Issue 생성 없이는 시작할 수 없다.
|
|
||||||
- 프로젝트 관리는 YouTrack에서, 형상관리는 Gitea에서 수행한다.
|
|
||||||
### 1. 이슈 등록 (***Require***)
|
|
||||||
- 작업할 단위 기능을 해당하는 YouTrack에 등록한다. (Gitea Issue 미사용)
|
|
||||||
|
|
||||||
#### 요약 (***Require***)
|
|
||||||
- `작업_제목` (예시: 로그인 API 구현)
|
|
||||||
- 기존의 `[분류]` 에 해당하는 부분은 `유형` 필드로 대체하므로 적지 않는다.
|
|
||||||
- 설명 (***Require***)
|
|
||||||
- 현재 작업하는 내용에 대한 리스트 작성한다.
|
|
||||||
- 그외의 기획서 링크가 있다면 본문에 첨부한다.
|
|
||||||
|
|
||||||
#### 필드 설정 (***Require***)
|
|
||||||
- 다음 사용자 지정 필드는 반드시 선택한다.
|
|
||||||
1. 파트 (***Require***): 작업할 서비스를 선택한다.
|
|
||||||
2. 우선순위 (***Require***): 긴급/ 높음/ 보통/ 낮음/ 없음
|
|
||||||
3. 유형 (***Require***): Feature(기능 개발)/ Bug(버그)/ Design(디자인)/ Refactor(리팩토링)/ Improvement(기능 개선)/ Chore(설정 관리)/ Documentation(문서 작업)
|
|
||||||
4. 상태 (***Require***): 대기/ 진행중/ 확인 대기/ 완료
|
|
||||||
5. Milestones
|
|
||||||
- 배포 버전 관리 (Gitea의 Milestones 생성 불필요)
|
|
||||||
- 형식: v1.0.0, MVP, CBT 등
|
|
||||||
6. Sprints
|
|
||||||
- 작업 기간 관리
|
|
||||||
- 형식: 2026-01 Sprint 1 등
|
|
||||||
7. 담당자
|
|
||||||
- 개발 담당(복수 선택 가능)을 선택한다.
|
|
||||||
- 이슈를 생성해줬다면 해당 이슈가 프로젝트에 맞게 애자일 보드에 배치다 잘 되었있는지 확인한다.
|
|
||||||
### 2. Branch 생성
|
|
||||||
- 1에서 생성한 YouTrack Issue ID(`SPMS-nn`) 확인후 브랜치 생성한다.
|
|
||||||
- 형식: `모델/이슈ID-작업요약`
|
|
||||||
- 모델: Git 전략의 [1. Branching Model (Git-Flow 방식)](./GitFlow.md/#1-branching-model-git-flow-방식) 참고
|
|
||||||
- 이슈ID: 이슈번호의 생략하지 않고 전부 작성한다.
|
|
||||||
- 작업 요약: `동사-명사` 형식으로 작성하며 영문으로 작성하고 둘간의 연결은 `-` 로 한다.
|
|
||||||
- 예: feature/SPMS-1-login-api
|
|
||||||
### 3. 개발 & Commit
|
|
||||||
- 로컬에서 작업 후 커밋시, YouTrack 연동을 위해 메시지 규칙을 엄수한다.
|
|
||||||
- 커밋 메시지의 형태는 [Conventional Commits](https://www.conventionalcommits.org/ko/v1.0.0/) 의 규칙을 따른다.
|
|
||||||
- Git 전략의 [2. Commit Message](./GitFlow.md/#2-commit-message) 참고
|
|
||||||
- 단, YouTrack 에 따라서 형식은 `타입: 설명 (이슈ID)` 로 지정한다.
|
|
||||||
- 예: feat: 로그인 UI 작업 (Close SPMS-1)
|
|
||||||
### 4. Pull Request (PR)
|
|
||||||
- 작업 완료 후 develop 브랜치로 PR을 생성한다.
|
|
||||||
- PR은 피치못한 사정이 있지 않는 한 최소 1명 이상의 리뷰를 거쳐야 한다.
|
|
||||||
- PR 본문에 작업 내용과 테스트 결과를 요약한다.
|
|
||||||
- Git 전략의 [3. Pull Request(PR) Message](./GitFlow.md/#3-pull-requestpr-message) 참고
|
|
||||||
### 5. Merge & Close
|
|
||||||
- 코드 리뷰 (Self-Review 포함) 후 Merge 한다.
|
|
||||||
- 자동 완료
|
|
||||||
- 커밋 메시지에 특정 명령어를 사용했다면 Merge 시 YouTrack 이슈가 자동으로 `해결됨` 상태로 지정된 값 중 최상단 값의 상태로 변경된다.
|
|
||||||
- 다른 상태로 변경하고 싶다면 이슈 ID 작성 부분에 해당 값의 영어 이름을 넣어준다.
|
|
||||||
- 대기: Available
|
|
||||||
- 진행 중: In Progress
|
|
||||||
- 확인 대기: To Verify
|
|
||||||
- 완료: Done
|
|
||||||
- 수동 완료**:** 명령어를 누락했다면, 개발자가 직접 YouTrack에서 상태를 변경해야 한다.
|
|
||||||
- YouTrack 애자일 보드는 '상태(State)' 필드에 따라 자동으로 카드가 이동하므로, 별도의 보드 관리가 필요 없다.
|
|
||||||
93
SPMS.API/Extensions/ApplicationBuilderExtensions.cs
Normal file
93
SPMS.API/Extensions/ApplicationBuilderExtensions.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using Serilog;
|
||||||
|
using SPMS.API.Middlewares;
|
||||||
|
|
||||||
|
namespace SPMS.API.Extensions;
|
||||||
|
|
||||||
|
public static class ApplicationBuilderExtensions
|
||||||
|
{
|
||||||
|
public static WebApplication UseMiddlewarePipeline(this WebApplication app)
|
||||||
|
{
|
||||||
|
// -- 1. 예외 처리 (최외곽 — 이후 모든 미들웨어 예외 포착) --
|
||||||
|
app.UseMiddleware<ExceptionMiddleware>();
|
||||||
|
|
||||||
|
// -- 2. 보안 헤더 주입 (미구현) --
|
||||||
|
// app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
|
||||||
|
// -- 3. X-Request-ID 발급/반환 (클라이언트 디버깅용) --
|
||||||
|
app.UseMiddleware<RequestIdMiddleware>();
|
||||||
|
|
||||||
|
// -- 4. Serilog 구조적 로깅 (X-Request-ID 이후에 위치) --
|
||||||
|
app.UseSerilogRequestLogging(options =>
|
||||||
|
{
|
||||||
|
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||||
|
{
|
||||||
|
if (httpContext.Items.TryGetValue("RequestId", out var requestId))
|
||||||
|
{
|
||||||
|
diagnosticContext.Set("RequestId", requestId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- 5. HTTPS 리다이렉션 (Nginx가 HTTPS 처리하므로 Production에서만) --
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 6. 정적 파일 서빙 --
|
||||||
|
var webRoot = app.Environment.WebRootPath;
|
||||||
|
if (Directory.Exists(webRoot))
|
||||||
|
{
|
||||||
|
app.UseStaticFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 7. 라우팅 --
|
||||||
|
app.UseRouting();
|
||||||
|
|
||||||
|
// -- 8. CORS (미구현) --
|
||||||
|
// app.UseCors("DefaultPolicy");
|
||||||
|
|
||||||
|
// -- 9. API 속도 제한 (IP별 분당 100회) --
|
||||||
|
app.UseRateLimiter();
|
||||||
|
|
||||||
|
// -- 10. JWT 인증 --
|
||||||
|
app.UseAuthentication();
|
||||||
|
|
||||||
|
// -- 11. 역할 인가 --
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// -- 12. X-Service-Code 서비스 식별 --
|
||||||
|
app.UseMiddleware<ServiceCodeMiddleware>();
|
||||||
|
|
||||||
|
// -- 13. X-API-KEY 검증 (SDK/디바이스 엔드포인트용) --
|
||||||
|
app.UseMiddleware<ApiKeyMiddleware>();
|
||||||
|
|
||||||
|
// -- 14. X-SPMS-TEST 샌드박스 모드 --
|
||||||
|
app.UseMiddleware<SandboxMiddleware>();
|
||||||
|
|
||||||
|
// -- 15. Swagger UI (개발 환경만) --
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI(options =>
|
||||||
|
{
|
||||||
|
options.SwaggerEndpoint("/swagger/all/swagger.json", "SPMS API - 전체");
|
||||||
|
options.SwaggerEndpoint("/swagger/public/swagger.json", "공개 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/auth/swagger.json", "인증 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/account/swagger.json", "계정 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/service/swagger.json", "서비스 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/device/swagger.json", "디바이스 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/message/swagger.json", "메시지 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/push/swagger.json", "푸시 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/stats/swagger.json", "통계 API");
|
||||||
|
options.SwaggerEndpoint("/swagger/file/swagger.json", "파일 API");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 16. 엔드포인트 매핑 --
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
SPMS.API/Extensions/AuthenticationExtensions.cs
Normal file
72
SPMS.API/Extensions/AuthenticationExtensions.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Application.Settings;
|
||||||
|
|
||||||
|
namespace SPMS.API.Extensions;
|
||||||
|
|
||||||
|
public static class AuthenticationExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddJwtAuthentication(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var jwtSettings = configuration.GetSection(JwtSettings.SectionName).Get<JwtSettings>()!;
|
||||||
|
|
||||||
|
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
|
||||||
|
|
||||||
|
services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
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.Zero
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
var tokenStore = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<ITokenStore>();
|
||||||
|
var jti = context.Principal?.FindFirst("jti")?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(jti))
|
||||||
|
{
|
||||||
|
var blacklisted = await tokenStore.GetAsync($"blacklist:{jti}");
|
||||||
|
if (blacklisted != null)
|
||||||
|
context.Fail("토큰이 무효화되었습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAuthorizationPolicies(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("SuperOnly", policy =>
|
||||||
|
policy.RequireRole("Super"));
|
||||||
|
|
||||||
|
options.AddPolicy("ManagerOrAbove", policy =>
|
||||||
|
policy.RequireRole("Super", "Manager"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
SPMS.API/Extensions/SwaggerExtensions.cs
Normal file
71
SPMS.API/Extensions/SwaggerExtensions.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using SPMS.API.Filters;
|
||||||
|
|
||||||
|
namespace SPMS.API.Extensions;
|
||||||
|
|
||||||
|
public static class SwaggerExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen(options =>
|
||||||
|
{
|
||||||
|
options.EnableAnnotations();
|
||||||
|
|
||||||
|
// API 문서 그룹
|
||||||
|
options.SwaggerDoc("all", new OpenApiInfo
|
||||||
|
{
|
||||||
|
Title = "SPMS API - 전체",
|
||||||
|
Version = "v1",
|
||||||
|
Description = "Stein Push Message Service API"
|
||||||
|
});
|
||||||
|
options.SwaggerDoc("public", new OpenApiInfo { Title = "공개 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("auth", new OpenApiInfo { Title = "인증 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("account", new OpenApiInfo { Title = "계정 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("service", new OpenApiInfo { Title = "서비스 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("device", new OpenApiInfo { Title = "디바이스 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("message", new OpenApiInfo { Title = "메시지 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("push", new OpenApiInfo { Title = "푸시 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("stats", new OpenApiInfo { Title = "통계 API", Version = "v1" });
|
||||||
|
options.SwaggerDoc("file", new OpenApiInfo { Title = "파일 API", Version = "v1" });
|
||||||
|
|
||||||
|
// 전체 문서에는 모든 API 포함, 나머지는 GroupName 기준 필터링
|
||||||
|
options.DocInclusionPredicate((docName, apiDesc) =>
|
||||||
|
{
|
||||||
|
if (docName == "all") return true;
|
||||||
|
return apiDesc.GroupName == docName;
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWT Bearer 인증 UI
|
||||||
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Description = "JWT Bearer Token을 입력하세요. 예: {token}",
|
||||||
|
Name = "Authorization",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "JWT"
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference
|
||||||
|
{
|
||||||
|
Type = ReferenceType.SecurityScheme,
|
||||||
|
Id = "Bearer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Array.Empty<string>()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPMS 커스텀 헤더 필터
|
||||||
|
options.OperationFilter<SpmsHeaderOperationFilter>();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
SPMS.API/Filters/SecureTransportAttribute.cs
Normal file
96
SPMS.API/Filters/SecureTransportAttribute.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using SPMS.Application.Interfaces;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Infrastructure.Security;
|
||||||
|
|
||||||
|
namespace SPMS.API.Filters;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||||
|
public class SecureTransportAttribute : Attribute, IAsyncResourceFilter
|
||||||
|
{
|
||||||
|
public async Task OnResourceExecutionAsync(
|
||||||
|
ResourceExecutingContext context,
|
||||||
|
ResourceExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var e2eeService = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<IE2EEService>();
|
||||||
|
|
||||||
|
// 1. Read raw request body
|
||||||
|
context.HttpContext.Request.EnableBuffering();
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await context.HttpContext.Request.Body.CopyToAsync(ms);
|
||||||
|
var encryptedPayload = ms.ToArray();
|
||||||
|
|
||||||
|
if (encryptedPayload.Length < 272)
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(
|
||||||
|
ApiResponse.Fail(ErrorCodes.BadRequest,
|
||||||
|
"암호화된 요청 데이터가 올바르지 않습니다."))
|
||||||
|
{ StatusCode = 400 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decrypt request
|
||||||
|
E2EEDecryptResult decryptResult;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decryptResult = e2eeService.DecryptRequest(encryptedPayload);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(
|
||||||
|
ApiResponse.Fail(ErrorCodes.BadRequest,
|
||||||
|
"요청 복호화에 실패했습니다."))
|
||||||
|
{ StatusCode = 400 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate timestamp
|
||||||
|
if (decryptResult.DecryptedBody.Length > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(decryptResult.DecryptedBody);
|
||||||
|
if (doc.RootElement.TryGetProperty("timestamp", out var ts))
|
||||||
|
{
|
||||||
|
if (!TimestampValidator.IsValid(ts.GetInt64()))
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(
|
||||||
|
ApiResponse.Fail(ErrorCodes.Unauthorized,
|
||||||
|
"요청 시간이 만료되었습니다."))
|
||||||
|
{ StatusCode = 401 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// timestamp 필드가 없거나 JSON이 아닌 경우 스킵
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store AES key for response encryption
|
||||||
|
context.HttpContext.Items["E2EE_AesKey"] = decryptResult.AesKey;
|
||||||
|
|
||||||
|
// 5. Replace request body with decrypted content
|
||||||
|
var decryptedStream = new MemoryStream(decryptResult.DecryptedBody);
|
||||||
|
context.HttpContext.Request.Body = decryptedStream;
|
||||||
|
context.HttpContext.Request.ContentLength = decryptResult.DecryptedBody.Length;
|
||||||
|
context.HttpContext.Request.ContentType = "application/json";
|
||||||
|
|
||||||
|
// 6. Execute action
|
||||||
|
var resultContext = await next();
|
||||||
|
|
||||||
|
// 7. Encrypt response
|
||||||
|
if (resultContext.Result is ObjectResult objectResult &&
|
||||||
|
context.HttpContext.Items.TryGetValue("E2EE_AesKey", out var keyObj) &&
|
||||||
|
keyObj is byte[] aesKey)
|
||||||
|
{
|
||||||
|
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(objectResult.Value);
|
||||||
|
var encrypted = e2eeService.EncryptResponse(jsonBytes, aesKey);
|
||||||
|
resultContext.Result = new FileContentResult(encrypted, "application/octet-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
SPMS.API/Filters/SpmsHeaderOperationFilter.cs
Normal file
63
SPMS.API/Filters/SpmsHeaderOperationFilter.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
|
||||||
|
namespace SPMS.API.Filters;
|
||||||
|
|
||||||
|
public class SpmsHeaderOperationFilter : IOperationFilter
|
||||||
|
{
|
||||||
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||||
|
{
|
||||||
|
var relativePath = context.ApiDescription.RelativePath ?? "";
|
||||||
|
|
||||||
|
// /v1/out/* 공개 API는 커스텀 헤더 불필요
|
||||||
|
if (relativePath.StartsWith("v1/out"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
operation.Parameters ??= new List<OpenApiParameter>();
|
||||||
|
|
||||||
|
// v1/in/* 중 X-Service-Code 대상 경로 판별
|
||||||
|
var isOptional = relativePath.StartsWith("v1/in/stats") ||
|
||||||
|
relativePath == "v1/in/device/list" ||
|
||||||
|
relativePath == "v1/in/message/list";
|
||||||
|
var isRequired = (relativePath.StartsWith("v1/in/message") && !isOptional) ||
|
||||||
|
relativePath.StartsWith("v1/in/push") ||
|
||||||
|
relativePath.StartsWith("v1/in/file");
|
||||||
|
var isDeviceNonList = relativePath.StartsWith("v1/in/device") && !isOptional;
|
||||||
|
|
||||||
|
if (isOptional)
|
||||||
|
{
|
||||||
|
operation.Parameters.Add(new OpenApiParameter
|
||||||
|
{
|
||||||
|
Name = "X-Service-Code",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Required = false,
|
||||||
|
Description = "서비스 식별 코드 (관리자: 미지정 시 전체 서비스 조회)",
|
||||||
|
Schema = new OpenApiSchema { Type = "string" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (isRequired || isDeviceNonList)
|
||||||
|
{
|
||||||
|
operation.Parameters.Add(new OpenApiParameter
|
||||||
|
{
|
||||||
|
Name = "X-Service-Code",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Required = true,
|
||||||
|
Description = "서비스 식별 코드",
|
||||||
|
Schema = new OpenApiSchema { Type = "string" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// /v1/in/device/* SDK/디바이스 전용 API는 X-API-KEY 필요
|
||||||
|
if (relativePath.StartsWith("v1/in/device"))
|
||||||
|
{
|
||||||
|
operation.Parameters.Add(new OpenApiParameter
|
||||||
|
{
|
||||||
|
Name = "X-API-KEY",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Required = true,
|
||||||
|
Description = "API 인증 키 (SDK/디바이스용)",
|
||||||
|
Schema = new OpenApiSchema { Type = "string" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
SPMS.API/Middlewares/ApiKeyMiddleware.cs
Normal file
50
SPMS.API/Middlewares/ApiKeyMiddleware.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace SPMS.API.Middlewares;
|
||||||
|
|
||||||
|
public class ApiKeyMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public ApiKeyMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context, IServiceRepository serviceRepository)
|
||||||
|
{
|
||||||
|
if (!RequiresApiKey(context.Request.Path))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.Request.Headers.TryGetValue("X-API-KEY", out var apiKey) ||
|
||||||
|
string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
ApiResponse.Fail(ErrorCodes.Unauthorized, "API Key가 필요합니다."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = await serviceRepository.GetByApiKeyAsync(apiKey!);
|
||||||
|
if (service == null)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
ApiResponse.Fail(ErrorCodes.Unauthorized, "유효하지 않은 API Key입니다."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Items["Service"] = service;
|
||||||
|
context.Items["ServiceId"] = service.Id;
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresApiKey(PathString path)
|
||||||
|
{
|
||||||
|
return path.StartsWithSegments("/v1/in/device") &&
|
||||||
|
!path.StartsWithSegments("/v1/in/device/list");
|
||||||
|
}
|
||||||
|
}
|
||||||
50
SPMS.API/Middlewares/ExceptionMiddleware.cs
Normal file
50
SPMS.API/Middlewares/ExceptionMiddleware.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SPMS.API.Middlewares;
|
||||||
|
|
||||||
|
public class ExceptionMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ExceptionMiddleware> _logger;
|
||||||
|
|
||||||
|
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (SpmsException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Business exception: {ErrorCode} {Message}", ex.ErrorCode, ex.Message);
|
||||||
|
await HandleSpmsExceptionAsync(context, ex);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
|
||||||
|
await HandleUnknownExceptionAsync(context, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleSpmsExceptionAsync(HttpContext context, SpmsException exception)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = exception.HttpStatusCode;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var response = ApiResponse.Fail(exception.ErrorCode, exception.Message);
|
||||||
|
await context.Response.WriteAsJsonAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleUnknownExceptionAsync(HttpContext context, Exception exception)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 500;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var response = ApiResponse.Fail(ErrorCodes.InternalError, "서버 내부 오류가 발생했습니다.");
|
||||||
|
await context.Response.WriteAsJsonAsync(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
SPMS.API/Middlewares/RequestIdMiddleware.cs
Normal file
19
SPMS.API/Middlewares/RequestIdMiddleware.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace SPMS.API.Middlewares;
|
||||||
|
|
||||||
|
public class RequestIdMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public RequestIdMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var requestId = context.Request.Headers["X-Request-ID"].FirstOrDefault()
|
||||||
|
?? Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
context.Items["RequestId"] = requestId;
|
||||||
|
context.Response.Headers["X-Request-ID"] = requestId;
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
SPMS.API/Middlewares/SandboxMiddleware.cs
Normal file
16
SPMS.API/Middlewares/SandboxMiddleware.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace SPMS.API.Middlewares;
|
||||||
|
|
||||||
|
public class SandboxMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public SandboxMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var isTest = context.Request.Headers["X-SPMS-TEST"].FirstOrDefault();
|
||||||
|
context.Items["IsSandbox"] = string.Equals(isTest, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
SPMS.API/Middlewares/ServiceCodeMiddleware.cs
Normal file
103
SPMS.API/Middlewares/ServiceCodeMiddleware.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
using SPMS.Domain.Common;
|
||||||
|
using SPMS.Domain.Enums;
|
||||||
|
using SPMS.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace SPMS.API.Middlewares;
|
||||||
|
|
||||||
|
public class ServiceCodeMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public ServiceCodeMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context, IServiceRepository serviceRepository)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path;
|
||||||
|
|
||||||
|
// === SKIP: X-Service-Code 불필요 ===
|
||||||
|
if (path == "/" ||
|
||||||
|
!path.StartsWithSegments("/v1") ||
|
||||||
|
path.StartsWithSegments("/v1/out") ||
|
||||||
|
path.StartsWithSegments("/v1/in/auth") ||
|
||||||
|
path.StartsWithSegments("/v1/in/account") ||
|
||||||
|
path.StartsWithSegments("/v1/in/public") ||
|
||||||
|
path.StartsWithSegments("/v1/in/service") ||
|
||||||
|
(path.StartsWithSegments("/v1/in/device") &&
|
||||||
|
!path.StartsWithSegments("/v1/in/device/list")) ||
|
||||||
|
path.StartsWithSegments("/swagger") ||
|
||||||
|
path.StartsWithSegments("/health"))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === OPTIONAL_FOR_ADMIN: 관리자는 X-Service-Code 선택 ===
|
||||||
|
if (path.StartsWithSegments("/v1/in/stats") ||
|
||||||
|
path.StartsWithSegments("/v1/in/device/list") ||
|
||||||
|
path.StartsWithSegments("/v1/in/message/list") ||
|
||||||
|
path == "/v1/in/tag/list")
|
||||||
|
{
|
||||||
|
if (context.Request.Headers.TryGetValue("X-Service-Code", out var optionalCode) &&
|
||||||
|
!string.IsNullOrWhiteSpace(optionalCode))
|
||||||
|
{
|
||||||
|
// 헤더가 있으면 기존 검증 수행
|
||||||
|
await ValidateAndSetService(context, serviceRepository, optionalCode!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헤더 없음 — 인증된 사용자만 전체 서비스 모드 허용
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
// ServiceId 미설정 = 전체 서비스 모드
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비인증 요청 → 에러
|
||||||
|
context.Response.StatusCode = 400;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
ApiResponse.Fail(ErrorCodes.ServiceScopeRequired, "X-Service-Code 헤더가 필요합니다."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === REQUIRED: X-Service-Code 필수 ===
|
||||||
|
if (!context.Request.Headers.TryGetValue("X-Service-Code", out var serviceCode) ||
|
||||||
|
string.IsNullOrWhiteSpace(serviceCode))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 400;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
ApiResponse.Fail(ErrorCodes.BadRequest, "X-Service-Code 헤더가 필요합니다."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ValidateAndSetService(context, serviceRepository, serviceCode!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidateAndSetService(HttpContext context, IServiceRepository serviceRepository, string serviceCode)
|
||||||
|
{
|
||||||
|
var service = await serviceRepository.GetByServiceCodeAsync(serviceCode);
|
||||||
|
if (service == null)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 404;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
ApiResponse.Fail(ErrorCodes.NotFound, "존재하지 않는 서비스입니다."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.Status != ServiceStatus.Active)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 503;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
ApiResponse.Fail(ErrorCodes.Unauthorized, "비활성 상태의 서비스입니다."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Items["Service"] = service;
|
||||||
|
context.Items["ServiceId"] = service.Id;
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,46 +1,86 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
using System.Threading.RateLimiting;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Serilog;
|
||||||
|
using SPMS.API.Extensions;
|
||||||
|
using SPMS.Application;
|
||||||
|
using SPMS.Domain.Common;
|
||||||
using SPMS.Infrastructure;
|
using SPMS.Infrastructure;
|
||||||
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||||
{
|
{
|
||||||
WebRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_WEBROOT")
|
WebRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_WEBROOT")
|
||||||
?? "wwwroot"
|
?? "wwwroot"
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
// ===== 1. Serilog =====
|
||||||
builder.Services.AddOpenApi();
|
builder.Host.UseSerilog((context, config) =>
|
||||||
|
config.ReadFrom.Configuration(context.Configuration));
|
||||||
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
// ===== 2. Services (DI) =====
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
// ===== 3. Presentation =====
|
||||||
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
|
builder.Services.AddControllers()
|
||||||
|
.ConfigureApiBehaviorOptions(options =>
|
||||||
|
{
|
||||||
|
options.InvalidModelStateResponseFactory = context =>
|
||||||
|
{
|
||||||
|
var fieldErrors = context.ModelState
|
||||||
|
.Where(e => e.Value?.Errors.Count > 0)
|
||||||
|
.SelectMany(e => e.Value!.Errors.Select(err => new FieldError
|
||||||
|
{
|
||||||
|
Field = e.Key,
|
||||||
|
Message = err.ErrorMessage
|
||||||
|
}))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var response = ApiResponseExtensions.ValidationFail(
|
||||||
|
ErrorCodes.BadRequest, "입력값 검증 실패", fieldErrors);
|
||||||
|
|
||||||
|
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(response);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
builder.Services.AddSwaggerDocumentation();
|
||||||
|
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddAuthorizationPolicies();
|
||||||
|
|
||||||
|
// ===== 4. Rate Limiting =====
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.OnRejected = async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.ContentType = "application/json";
|
||||||
|
var response = ApiResponse.Fail(
|
||||||
|
ErrorCodes.LimitExceeded, "요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
await context.HttpContext.Response.WriteAsJsonAsync(response, cancellationToken);
|
||||||
|
};
|
||||||
|
options.AddFixedWindowLimiter("auth_sensitive", opt =>
|
||||||
|
{
|
||||||
|
opt.PermitLimit = 20;
|
||||||
|
opt.Window = TimeSpan.FromMinutes(15);
|
||||||
|
});
|
||||||
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 100,
|
||||||
|
Window = TimeSpan.FromMinutes(1)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// ===== 5. DB 마이그레이션 자동 적용 =====
|
||||||
if (app.Environment.IsDevelopment())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
var db = scope.ServiceProvider.GetRequiredService<SPMS.Infrastructure.AppDbContext>();
|
||||||
}
|
await db.Database.MigrateAsync();
|
||||||
var webRoot = app.Environment.WebRootPath;
|
|
||||||
Console.WriteLine($"[System] Web Root Path: {webRoot}"); // 로그에 경로 찍어보기
|
|
||||||
|
|
||||||
if (Directory.Exists(webRoot))
|
|
||||||
{
|
|
||||||
app.UseStaticFiles(); // 경로가 있으면 파일 서빙
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Error] Web root folder not found!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
// ===== 6. Middleware Pipeline =====
|
||||||
app.UseRouting();
|
app.UseMiddlewarePipeline();
|
||||||
|
|
||||||
// [4] 요청 처리
|
|
||||||
app.MapControllers();
|
|
||||||
app.MapFallbackToFile("index.html");
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
|
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.9.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
{
|
{
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": ""
|
"DefaultConnection": ""
|
||||||
|
|
@ -22,5 +16,30 @@
|
||||||
"UserName": "",
|
"UserName": "",
|
||||||
"Password": "",
|
"Password": "",
|
||||||
"VirtualHost": "dev"
|
"VirtualHost": "dev"
|
||||||
|
},
|
||||||
|
"CredentialEncryption": {
|
||||||
|
"Key": ""
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Override": {
|
||||||
|
"SPMS": "Debug",
|
||||||
|
"Microsoft.AspNetCore": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{
|
||||||
|
"Name": "File",
|
||||||
|
"Args": {
|
||||||
|
"path": "Logs/spms-.log",
|
||||||
|
"rollingInterval": "Day",
|
||||||
|
"retainedFileCountLimit": 7,
|
||||||
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{RequestId}] {Message:lj}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
{
|
{
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": ""
|
"DefaultConnection": ""
|
||||||
|
|
@ -13,13 +7,58 @@
|
||||||
"SecretKey": "",
|
"SecretKey": "",
|
||||||
"Issuer": "SPMS",
|
"Issuer": "SPMS",
|
||||||
"Audience": "SPMS",
|
"Audience": "SPMS",
|
||||||
"ExpiryMinutes": 10
|
"ExpiryMinutes": 10,
|
||||||
|
"RefreshTokenExpiryDays": 7
|
||||||
},
|
},
|
||||||
"RabbitMQ": {
|
"RabbitMQ": {
|
||||||
"HostName": "",
|
"HostName": "",
|
||||||
"Port": 0,
|
"Port": 0,
|
||||||
"UserName": "",
|
"UserName": "",
|
||||||
"Password": "",
|
"Password": "",
|
||||||
"VirtualHost": "/"
|
"VirtualHost": "/",
|
||||||
|
"Exchange": "spms.push.exchange",
|
||||||
|
"PushQueue": "spms.push.queue",
|
||||||
|
"ScheduleQueue": "spms.schedule.queue",
|
||||||
|
"PrefetchCount": 10,
|
||||||
|
"MessageTtl": 86400000
|
||||||
|
},
|
||||||
|
"Redis": {
|
||||||
|
"ConnectionString": "",
|
||||||
|
"InstanceName": "spms_dev_",
|
||||||
|
"DuplicateTtlHours": 24
|
||||||
|
},
|
||||||
|
"CredentialEncryption": {
|
||||||
|
"Key": ""
|
||||||
|
},
|
||||||
|
"FileStorage": {
|
||||||
|
"UploadPath": "Uploads",
|
||||||
|
"BaseUrl": "/uploads"
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Override": {
|
||||||
|
"SPMS": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{
|
||||||
|
"Name": "File",
|
||||||
|
"Args": {
|
||||||
|
"path": "Logs/spms-.log",
|
||||||
|
"rollingInterval": "Day",
|
||||||
|
"retainedFileCountLimit": 30,
|
||||||
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{RequestId}] {Message:lj}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Enrich": ["FromLogContext"],
|
||||||
|
"Properties": {
|
||||||
|
"Application": "SPMS_API"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
15
SPMS.Application/DTOs/Account/AccountListRequestDto.cs
Normal file
15
SPMS.Application/DTOs/Account/AccountListRequestDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class AccountListRequestDto
|
||||||
|
{
|
||||||
|
[Range(1, int.MaxValue, ErrorMessage = "페이지 번호는 1 이상이어야 합니다.")]
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
[Range(1, 100, ErrorMessage = "페이지 크기는 1~100 사이여야 합니다.")]
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
|
||||||
|
public string? SearchKeyword { get; set; }
|
||||||
|
public int? Role { get; set; }
|
||||||
|
}
|
||||||
10
SPMS.Application/DTOs/Account/AccountListResponseDto.cs
Normal file
10
SPMS.Application/DTOs/Account/AccountListResponseDto.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class AccountListResponseDto
|
||||||
|
{
|
||||||
|
public List<AccountResponseDto> Items { get; set; } = new();
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public int Page { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||||
|
}
|
||||||
13
SPMS.Application/DTOs/Account/AccountResponseDto.cs
Normal file
13
SPMS.Application/DTOs/Account/AccountResponseDto.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class AccountResponseDto
|
||||||
|
{
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public bool EmailVerified { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
}
|
||||||
18
SPMS.Application/DTOs/Account/ActivityListRequestDto.cs
Normal file
18
SPMS.Application/DTOs/Account/ActivityListRequestDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class ActivityListRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("page")]
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public int Size { get; set; } = 10;
|
||||||
|
|
||||||
|
[JsonPropertyName("from")]
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("to")]
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
}
|
||||||
31
SPMS.Application/DTOs/Account/ActivityListResponseDto.cs
Normal file
31
SPMS.Application/DTOs/Account/ActivityListResponseDto.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SPMS.Application.DTOs.Notice;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class ActivityListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<ActivityItemDto> Items { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("pagination")]
|
||||||
|
public PaginationDto Pagination { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ActivityItemDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("activity_type")]
|
||||||
|
public string ActivityType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ip_address")]
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("occurred_at")]
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
}
|
||||||
24
SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs
Normal file
24
SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class CreateAccountRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일을 입력해주세요.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "비밀번호를 입력해주세요.")]
|
||||||
|
[MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "이름을 입력해주세요.")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Phone(ErrorMessage = "올바른 전화번호 형식이 아닙니다.")]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "권한을 선택해주세요.")]
|
||||||
|
[Range(1, 2, ErrorMessage = "권한은 Manager(1) 또는 User(2)만 가능합니다.")]
|
||||||
|
public int Role { get; set; }
|
||||||
|
}
|
||||||
20
SPMS.Application/DTOs/Account/OperatorCreateRequestDto.cs
Normal file
20
SPMS.Application/DTOs/Account/OperatorCreateRequestDto.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class OperatorCreateRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일을 입력해주세요.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "이름을 입력해주세요.")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Phone(ErrorMessage = "올바른 전화번호 형식이 아닙니다.")]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "권한을 선택해주세요.")]
|
||||||
|
[Range(1, 2, ErrorMessage = "권한은 Manager(1) 또는 User(2)만 가능합니다.")]
|
||||||
|
public int Role { get; set; }
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/Account/OperatorCreateResponseDto.cs
Normal file
15
SPMS.Application/DTOs/Account/OperatorCreateResponseDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class OperatorCreateResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public int Role { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/Account/OperatorDeleteRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Account/OperatorDeleteRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class OperatorDeleteRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "운영자 코드를 입력해주세요.")]
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
18
SPMS.Application/DTOs/Account/OperatorListRequestDto.cs
Normal file
18
SPMS.Application/DTOs/Account/OperatorListRequestDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class OperatorListRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("page")]
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public int Size { get; set; } = 20;
|
||||||
|
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public int? Role { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_active")]
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
}
|
||||||
37
SPMS.Application/DTOs/Account/OperatorListResponseDto.cs
Normal file
37
SPMS.Application/DTOs/Account/OperatorListResponseDto.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SPMS.Application.DTOs.Notice;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class OperatorListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<OperatorItemDto> Items { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("pagination")]
|
||||||
|
public PaginationDto Pagination { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OperatorItemDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public int Role { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_active")]
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("last_login_at")]
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class OperatorPasswordResetRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "운영자 코드를 입력해주세요.")]
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
10
SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs
Normal file
10
SPMS.Application/DTOs/Account/PasswordForgotRequestDto.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class PasswordForgotRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일은 필수입니다.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
20
SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs
Normal file
20
SPMS.Application/DTOs/Account/PasswordResetRequestDto.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class PasswordResetRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일은 필수입니다.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "재설정 토큰은 필수입니다.")]
|
||||||
|
[JsonPropertyName("reset_token")]
|
||||||
|
public string ResetToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "새 비밀번호는 필수입니다.")]
|
||||||
|
[MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")]
|
||||||
|
[JsonPropertyName("new_password")]
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
30
SPMS.Application/DTOs/Account/ProfileResponseDto.cs
Normal file
30
SPMS.Application/DTOs/Account/ProfileResponseDto.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class ProfileResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("phone")]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public int Role { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("last_login_at")]
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("organization")]
|
||||||
|
public string? Organization { get; set; }
|
||||||
|
}
|
||||||
12
SPMS.Application/DTOs/Account/TempPasswordRequestDto.cs
Normal file
12
SPMS.Application/DTOs/Account/TempPasswordRequestDto.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class TempPasswordRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
16
SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs
Normal file
16
SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class UpdateAccountRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이름을 입력해주세요.")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Phone(ErrorMessage = "올바른 전화번호 형식이 아닙니다.")]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "권한을 선택해주세요.")]
|
||||||
|
[Range(1, 2, ErrorMessage = "권한은 Manager(1) 또는 User(2)만 가능합니다.")]
|
||||||
|
public int Role { get; set; }
|
||||||
|
}
|
||||||
18
SPMS.Application/DTOs/Account/UpdateProfileRequestDto.cs
Normal file
18
SPMS.Application/DTOs/Account/UpdateProfileRequestDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Account;
|
||||||
|
|
||||||
|
public class UpdateProfileRequestDto
|
||||||
|
{
|
||||||
|
[StringLength(50, ErrorMessage = "이름은 50자 이내여야 합니다.")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[Phone(ErrorMessage = "올바른 전화번호 형식이 아닙니다.")]
|
||||||
|
[StringLength(20, ErrorMessage = "전화번호는 20자 이내여야 합니다.")]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("organization")]
|
||||||
|
[StringLength(100, ErrorMessage = "소속은 100자 이내여야 합니다.")]
|
||||||
|
public string? Organization { get; set; }
|
||||||
|
}
|
||||||
9
SPMS.Application/DTOs/AppConfig/AppConfigResponseDto.cs
Normal file
9
SPMS.Application/DTOs/AppConfig/AppConfigResponseDto.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.AppConfig;
|
||||||
|
|
||||||
|
public class AppConfigResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")]
|
||||||
|
public string? Url { get; set; }
|
||||||
|
}
|
||||||
21
SPMS.Application/DTOs/AppConfig/AppSettingsResponseDto.cs
Normal file
21
SPMS.Application/DTOs/AppConfig/AppSettingsResponseDto.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.AppConfig;
|
||||||
|
|
||||||
|
public class AppSettingsResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("min_app_version")]
|
||||||
|
public string? MinAppVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_maintenance")]
|
||||||
|
public bool IsMaintenance { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("maintenance_msg")]
|
||||||
|
public string? MaintenanceMsg { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cs_email")]
|
||||||
|
public string? CsEmail { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cs_phone")]
|
||||||
|
public string? CsPhone { get; set; }
|
||||||
|
}
|
||||||
12
SPMS.Application/DTOs/AppConfig/AppVersionRequestDto.cs
Normal file
12
SPMS.Application/DTOs/AppConfig/AppVersionRequestDto.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.AppConfig;
|
||||||
|
|
||||||
|
public class AppVersionRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public string Platform { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("app_version")]
|
||||||
|
public string AppVersion { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
18
SPMS.Application/DTOs/AppConfig/AppVersionResponseDto.cs
Normal file
18
SPMS.Application/DTOs/AppConfig/AppVersionResponseDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.AppConfig;
|
||||||
|
|
||||||
|
public class AppVersionResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("latest_version")]
|
||||||
|
public string? LatestVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("min_version")]
|
||||||
|
public string? MinVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_force_update")]
|
||||||
|
public bool IsForceUpdate { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("update_url")]
|
||||||
|
public string? UpdateUrl { get; set; }
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/AppConfig/MaintenanceResponseDto.cs
Normal file
15
SPMS.Application/DTOs/AppConfig/MaintenanceResponseDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.AppConfig;
|
||||||
|
|
||||||
|
public class MaintenanceResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("is_maintenance")]
|
||||||
|
public bool IsMaintenance { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("maintenance_msg")]
|
||||||
|
public string? MaintenanceMsg { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("estimated_end_time")]
|
||||||
|
public string? EstimatedEndTime { get; set; }
|
||||||
|
}
|
||||||
17
SPMS.Application/DTOs/Auth/ChangePasswordRequestDto.cs
Normal file
17
SPMS.Application/DTOs/Auth/ChangePasswordRequestDto.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class ChangePasswordRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "현재 비밀번호를 입력해주세요.")]
|
||||||
|
public string CurrentPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "새 비밀번호를 입력해주세요.")]
|
||||||
|
[MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")]
|
||||||
|
[MaxLength(64, ErrorMessage = "비밀번호는 64자 이하여야 합니다.")]
|
||||||
|
[RegularExpression(
|
||||||
|
@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':""\\|,.<>\/?]).{8,}$",
|
||||||
|
ErrorMessage = "비밀번호는 영문 대/소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다.")]
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
9
SPMS.Application/DTOs/Auth/ChangePasswordResponseDto.cs
Normal file
9
SPMS.Application/DTOs/Auth/ChangePasswordResponseDto.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class ChangePasswordResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("re_login_required")]
|
||||||
|
public bool ReLoginRequired { get; set; }
|
||||||
|
}
|
||||||
10
SPMS.Application/DTOs/Auth/EmailCheckRequestDto.cs
Normal file
10
SPMS.Application/DTOs/Auth/EmailCheckRequestDto.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class EmailCheckRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일은 필수입니다.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/Auth/EmailCheckResponseDto.cs
Normal file
11
SPMS.Application/DTOs/Auth/EmailCheckResponseDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class EmailCheckResponseDto
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("is_available")]
|
||||||
|
public bool IsAvailable { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Auth/EmailResendRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class EmailResendRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "인증 세션 ID는 필수입니다.")]
|
||||||
|
[JsonPropertyName("verify_session_id")]
|
||||||
|
public string VerifySessionId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs
Normal file
15
SPMS.Application/DTOs/Auth/EmailResendResponseDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class EmailResendResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("resent")]
|
||||||
|
public bool Resent { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cooldown_seconds")]
|
||||||
|
public int CooldownSeconds { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in_seconds")]
|
||||||
|
public int ExpiresInSeconds { get; set; }
|
||||||
|
}
|
||||||
18
SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs
Normal file
18
SPMS.Application/DTOs/Auth/EmailVerifyRequestDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class EmailVerifyRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("verify_session_id")]
|
||||||
|
public string? VerifySessionId { get; set; }
|
||||||
|
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "인증 코드는 필수입니다.")]
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
12
SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs
Normal file
12
SPMS.Application/DTOs/Auth/EmailVerifyResponseDto.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class EmailVerifyResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("verified")]
|
||||||
|
public bool Verified { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("next_action")]
|
||||||
|
public string NextAction { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
13
SPMS.Application/DTOs/Auth/LoginRequestDto.cs
Normal file
13
SPMS.Application/DTOs/Auth/LoginRequestDto.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class LoginRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일은 필수입니다.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "비밀번호는 필수입니다.")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
48
SPMS.Application/DTOs/Auth/LoginResponseDto.cs
Normal file
48
SPMS.Application/DTOs/Auth/LoginResponseDto.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class LoginResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("next_action")]
|
||||||
|
public string NextAction { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("email_verified")]
|
||||||
|
public bool EmailVerified { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("verify_session_id")]
|
||||||
|
public string? VerifySessionId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email_sent")]
|
||||||
|
public bool? EmailSent { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("must_change_password")]
|
||||||
|
public bool? MustChangePassword { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("admin")]
|
||||||
|
public AdminInfoDto? Admin { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AdminInfoDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
12
SPMS.Application/DTOs/Auth/LogoutResponseDto.cs
Normal file
12
SPMS.Application/DTOs/Auth/LogoutResponseDto.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class LogoutResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("logged_out")]
|
||||||
|
public bool LoggedOut { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("redirect_to")]
|
||||||
|
public string RedirectTo { get; set; } = "/login";
|
||||||
|
}
|
||||||
28
SPMS.Application/DTOs/Auth/SignupRequestDto.cs
Normal file
28
SPMS.Application/DTOs/Auth/SignupRequestDto.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class SignupRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "이메일은 필수입니다.")]
|
||||||
|
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "비밀번호는 필수입니다.")]
|
||||||
|
[MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "이름은 필수입니다.")]
|
||||||
|
[StringLength(50, ErrorMessage = "이름은 50자 이내여야 합니다.")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "전화번호는 필수입니다.")]
|
||||||
|
[StringLength(20, ErrorMessage = "전화번호는 20자 이내여야 합니다.")]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "서비스 이용약관 동의는 필수입니다.")]
|
||||||
|
public bool AgreeTerms { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "개인정보 처리방침 동의는 필수입니다.")]
|
||||||
|
public bool AgreePrivacy { get; set; }
|
||||||
|
}
|
||||||
18
SPMS.Application/DTOs/Auth/SignupResponseDto.cs
Normal file
18
SPMS.Application/DTOs/Auth/SignupResponseDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class SignupResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("admin_code")]
|
||||||
|
public string AdminCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("verify_session_id")]
|
||||||
|
public string? VerifySessionId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email_sent")]
|
||||||
|
public bool EmailSent { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class TokenRefreshRequestDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "Refresh Token은 필수입니다.")]
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs
Normal file
15
SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Auth;
|
||||||
|
|
||||||
|
public class TokenRefreshResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
}
|
||||||
6
SPMS.Application/DTOs/Banner/BannerListRequestDto.cs
Normal file
6
SPMS.Application/DTOs/Banner/BannerListRequestDto.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace SPMS.Application.DTOs.Banner;
|
||||||
|
|
||||||
|
public class BannerListRequestDto
|
||||||
|
{
|
||||||
|
public string? Position { get; set; }
|
||||||
|
}
|
||||||
36
SPMS.Application/DTOs/Banner/BannerListResponseDto.cs
Normal file
36
SPMS.Application/DTOs/Banner/BannerListResponseDto.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Banner;
|
||||||
|
|
||||||
|
public class BannerListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<BannerItemDto> Items { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("total_count")]
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BannerItemDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("banner_id")]
|
||||||
|
public long BannerId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("image_url")]
|
||||||
|
public string ImageUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("link_url")]
|
||||||
|
public string? LinkUrl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("link_type")]
|
||||||
|
public string? LinkType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("position")]
|
||||||
|
public string Position { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("sort_order")]
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
19
SPMS.Application/DTOs/Device/DeviceAgreeRequestDto.cs
Normal file
19
SPMS.Application/DTOs/Device/DeviceAgreeRequestDto.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceAgreeRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("push_agreed")]
|
||||||
|
public bool PushAgreed { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("marketing_agreed")]
|
||||||
|
public bool MarketingAgreed { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/Device/DeviceDeleteRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Device/DeviceDeleteRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceDeleteRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
24
SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs
Normal file
24
SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceExportRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public string? Platform { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("push_agreed")]
|
||||||
|
public bool? PushAgreed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<string>? Tags { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_active")]
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("keyword")]
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("marketing_agreed")]
|
||||||
|
public bool? MarketingAgreed { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/Device/DeviceInfoRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Device/DeviceInfoRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceInfoRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
42
SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs
Normal file
42
SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceInfoResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("device_token")]
|
||||||
|
public string DeviceToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public string Platform { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("os_version")]
|
||||||
|
public string? OsVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("app_version")]
|
||||||
|
public string? AppVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string? Model { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("push_agreed")]
|
||||||
|
public bool PushAgreed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("marketing_agreed")]
|
||||||
|
public bool MarketingAgreed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<string>? Tags { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_active")]
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("last_active_at")]
|
||||||
|
public DateTime? LastActiveAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
30
SPMS.Application/DTOs/Device/DeviceListRequestDto.cs
Normal file
30
SPMS.Application/DTOs/Device/DeviceListRequestDto.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceListRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("page")]
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public int Size { get; set; } = 20;
|
||||||
|
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public string? Platform { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("push_agreed")]
|
||||||
|
public bool? PushAgreed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<string>? Tags { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_active")]
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("keyword")]
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("marketing_agreed")]
|
||||||
|
public bool? MarketingAgreed { get; set; }
|
||||||
|
}
|
||||||
58
SPMS.Application/DTOs/Device/DeviceListResponseDto.cs
Normal file
58
SPMS.Application/DTOs/Device/DeviceListResponseDto.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SPMS.Application.DTOs.Notice;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<DeviceSummaryDto> Items { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("pagination")]
|
||||||
|
public PaginationDto Pagination { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DeviceSummaryDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public string Platform { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string? Model { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("push_agreed")]
|
||||||
|
public bool PushAgreed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<string>? Tags { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("last_active_at")]
|
||||||
|
public DateTime? LastActiveAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("device_token")]
|
||||||
|
public string DeviceToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("service_name")]
|
||||||
|
public string ServiceName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("service_code")]
|
||||||
|
public string ServiceCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("os_version")]
|
||||||
|
public string? OsVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("app_version")]
|
||||||
|
public string? AppVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("marketing_agreed")]
|
||||||
|
public bool MarketingAgreed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_active")]
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
28
SPMS.Application/DTOs/Device/DeviceRegisterRequestDto.cs
Normal file
28
SPMS.Application/DTOs/Device/DeviceRegisterRequestDto.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceRegisterRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_token")]
|
||||||
|
public string DeviceToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public string Platform { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("os_version")]
|
||||||
|
public string? OsVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("app_version")]
|
||||||
|
public string? AppVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string? Model { get; set; }
|
||||||
|
}
|
||||||
12
SPMS.Application/DTOs/Device/DeviceRegisterResponseDto.cs
Normal file
12
SPMS.Application/DTOs/Device/DeviceRegisterResponseDto.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceRegisterResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("is_new")]
|
||||||
|
public bool IsNew { get; set; }
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/Device/DeviceTagsRequestDto.cs
Normal file
15
SPMS.Application/DTOs/Device/DeviceTagsRequestDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceTagsRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<string> Tags { get; set; } = new();
|
||||||
|
}
|
||||||
20
SPMS.Application/DTOs/Device/DeviceUpdateRequestDto.cs
Normal file
20
SPMS.Application/DTOs/Device/DeviceUpdateRequestDto.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Device;
|
||||||
|
|
||||||
|
public class DeviceUpdateRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("device_id")]
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("device_token")]
|
||||||
|
public string? DeviceToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("os_version")]
|
||||||
|
public string? OsVersion { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("app_version")]
|
||||||
|
public string? AppVersion { get; set; }
|
||||||
|
}
|
||||||
6
SPMS.Application/DTOs/Faq/FaqListRequestDto.cs
Normal file
6
SPMS.Application/DTOs/Faq/FaqListRequestDto.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace SPMS.Application.DTOs.Faq;
|
||||||
|
|
||||||
|
public class FaqListRequestDto
|
||||||
|
{
|
||||||
|
public string? Category { get; set; }
|
||||||
|
}
|
||||||
30
SPMS.Application/DTOs/Faq/FaqListResponseDto.cs
Normal file
30
SPMS.Application/DTOs/Faq/FaqListResponseDto.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.Faq;
|
||||||
|
|
||||||
|
public class FaqListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<FaqItemDto> Items { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("total_count")]
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FaqItemDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("faq_id")]
|
||||||
|
public long FaqId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("category")]
|
||||||
|
public string? Category { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("question")]
|
||||||
|
public string Question { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("answer")]
|
||||||
|
public string Answer { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("sort_order")]
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs
Normal file
11
SPMS.Application/DTOs/File/CsvTemplateRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class CsvTemplateRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("message_code")]
|
||||||
|
public string MessageCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
12
SPMS.Application/DTOs/File/CsvValidateErrorDto.cs
Normal file
12
SPMS.Application/DTOs/File/CsvValidateErrorDto.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class CsvValidateErrorDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("row")]
|
||||||
|
public int Row { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string Error { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
27
SPMS.Application/DTOs/File/CsvValidateResponseDto.cs
Normal file
27
SPMS.Application/DTOs/File/CsvValidateResponseDto.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class CsvValidateResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("total_rows")]
|
||||||
|
public int TotalRows { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("valid_rows")]
|
||||||
|
public int ValidRows { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("invalid_rows")]
|
||||||
|
public int InvalidRows { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("columns")]
|
||||||
|
public List<string> Columns { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("required_variables")]
|
||||||
|
public List<string> RequiredVariables { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("matched")]
|
||||||
|
public bool Matched { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errors")]
|
||||||
|
public List<CsvValidateErrorDto> Errors { get; set; } = new();
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/File/FileDeleteRequestDto.cs
Normal file
11
SPMS.Application/DTOs/File/FileDeleteRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class FileDeleteRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("file_id")]
|
||||||
|
public long FileId { get; set; }
|
||||||
|
}
|
||||||
11
SPMS.Application/DTOs/File/FileInfoRequestDto.cs
Normal file
11
SPMS.Application/DTOs/File/FileInfoRequestDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class FileInfoRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[JsonPropertyName("file_id")]
|
||||||
|
public long FileId { get; set; }
|
||||||
|
}
|
||||||
30
SPMS.Application/DTOs/File/FileInfoResponseDto.cs
Normal file
30
SPMS.Application/DTOs/File/FileInfoResponseDto.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class FileInfoResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("file_id")]
|
||||||
|
public long FileId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("service_code")]
|
||||||
|
public string ServiceCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("file_name")]
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("file_url")]
|
||||||
|
public string FileUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("file_size")]
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("file_type")]
|
||||||
|
public string FileType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("uploaded_by")]
|
||||||
|
public string UploadedBy { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/File/FileListRequestDto.cs
Normal file
15
SPMS.Application/DTOs/File/FileListRequestDto.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class FileListRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("page")]
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public int Size { get; set; } = 20;
|
||||||
|
|
||||||
|
[JsonPropertyName("file_type")]
|
||||||
|
public string? FileType { get; set; }
|
||||||
|
}
|
||||||
13
SPMS.Application/DTOs/File/FileListResponseDto.cs
Normal file
13
SPMS.Application/DTOs/File/FileListResponseDto.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SPMS.Application.DTOs.Notice;
|
||||||
|
|
||||||
|
namespace SPMS.Application.DTOs.File;
|
||||||
|
|
||||||
|
public class FileListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<FileSummaryDto> Items { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("pagination")]
|
||||||
|
public PaginationDto Pagination { get; set; } = null!;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user