Compare commits

..

256 Commits

Author SHA1 Message Date
bb748a66a3 fix: Production 로그 누락 및 DB 마이그레이션 미적용 수정 (#277)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/278
2026-03-18 00:24:43 +00:00
SEAN
2adb34acab fix: Production 로그 누락 및 DB 마이그레이션 미적용 수정 (#277)
- appsettings.json Serilog WriteTo에 Console 싱크 추가 (docker logs 출력)
- Program.cs 스타트업 시 db.Database.MigrateAsync() 자동 적용

Closes #277
2026-03-18 09:15:26 +09:00
b2485569be improvement: Device External ID (UUID) 도입 (#275)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/276
2026-03-03 03:57:50 +00:00
SEAN
44f6defa84 improvement: Device External ID (UUID) 도입 (#275) 2026-03-03 12:55:53 +09:00
8ccde89dc0 improvement: ApnsSender 환경별 APNs 호스트 자동 분기 (#273)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/274
2026-03-03 01:41:41 +00:00
SEAN
420a036c36 improvement: ApnsSender 환경별 APNs 호스트 자동 분기 (#273)
Closes #273
2026-03-03 10:37:35 +09:00
c97ae32080 fix: PushWorker APNs/FCM 크리덴셜 복호화 누락 수정 (#271)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/272
2026-03-03 01:14:31 +00:00
SEAN
b8d87377b9 fix: PushWorker APNs/FCM 크리덴셜 복호화 누락 수정 (#271)
Closes #271
2026-03-03 10:10:29 +09:00
6c3a384a99 improvement: TagCode 도입 — 태그 식별자를 4자리 랜덤 코드로 변경 (#269)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/270
2026-03-02 07:15:24 +00:00
SEAN
71cd7a5e98 improvement: TagCode 도입 — 태그 식별자를 4자리 랜덤 코드로 변경 (#269)
Closes #269
2026-03-02 16:12:06 +09:00
165328b7df improvement: 태그 관리 API 프론트엔드 연동 수정 (#267)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/268
2026-03-02 05:01:36 +00:00
SEAN
1ca4980293 improvement: 태그 관리 API 프론트엔드 연동 수정 (#267)
- TagSummaryDto에 tag_index 필드 추가 (서비스별 Id 순서 1-based 동적 계산)
- ServiceSummaryDto/ServiceResponseDto에 service_id 필드 추가
- ServiceCodeMiddleware OPTIONAL_FOR_ADMIN에 /v1/in/tag 경로 추가

Closes #267
2026-03-02 13:43:14 +09:00
432fde0baf chore: Stats Controller Swagger 응답 스키마 추가 (#265)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/266
2026-02-28 16:53:11 +00:00
SEAN
cb4cf01c4f chore: Stats Controller Swagger 응답 스키마 추가 (#265)
- StatsController 10개 메서드에 [ProducesResponseType] 어노테이션 추가
- Swagger 문서에서 Stats API 응답 스키마(DashboardResponseDto 등) 노출

Closes #265
2026-03-01 01:51:32 +09:00
e0af7cd604 fix: AdminCode 컬럼 길이 불일치 수정 (#257)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/264
2026-02-28 14:19:53 +00:00
42bf814af1 improvement: 대시보드 KPI 변화량/변화율 필드 추가 (#262)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/263
2026-02-28 14:06:13 +00:00
SEAN
9dcdd56b2f improvement: 대시보드 KPI 변화량/변화율 필드 추가 (#262)
- DashboardKpiDto에 success_rate_change, device_count_change, today_sent_change_rate 필드 추가
- StatsService.GetDashboardAsync에 직전 기간 성공률 변화, 오늘 신규 디바이스 수, 발송 변화율 계산 로직 구현

Closes #262
2026-02-28 22:50:31 +09:00
b02910a213 fix: JWT 토큰에 adminId 클레임 추가 (#260)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/261
2026-02-28 09:37:17 +00:00
SEAN
748aa3e3b8 fix: JWT 토큰에 adminId 클레임 추가 (#260)
- GenerateAccessToken에서 adminId 클레임을 별도로 추가
- 컨트롤러에서 User.FindFirst("adminId")로 조회 가능하도록 수정

Closes #260
2026-02-28 18:34:40 +09:00
9164d9156b fix: 컨트롤러 권한(Authorization) 설정 오류 수정 (#258)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/259
2026-02-28 09:22:38 +00:00
SEAN
3ea873e438 fix: 컨트롤러 권한(Authorization) 설정 오류 수정 (#258)
- ServiceController: [Authorize(Roles = "Super")] → [Authorize]
- AccountController: [Authorize(Roles = "Super")] → [Authorize]
- MessageController: [Authorize] 추가
- StatsController: [Authorize] 추가
- PushController: [Authorize] 추가

Closes #258
2026-02-28 18:15:46 +09:00
SEAN
ecddbe1c26 fix: AdminCode 컬럼 길이 불일치 수정 (#257)
- HasMaxLength(8) → HasMaxLength(12) 변경
- 코드에서 12자 UUID를 생성하나 DB가 8자만 허용하는 버그
- 회원가입 시 'Data too long for column AdminCode' 500 에러 해결
2026-02-26 17:36:33 +09:00
acfa988c43 fix: 루트 경로 X-Service-Code 미들웨어 차단 해제 (#255)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/256
2026-02-26 06:06:42 +00:00
SEAN
30c40d449d fix: 루트 경로 접근 시 X-Service-Code 미들웨어 차단 해제 (#255)
- /v1로 시작하지 않는 경로(루트, 정적파일 등)는 ServiceCodeMiddleware SKIP
- 프론트엔드 SPA(index.html) 정상 서빙 보장

Fixes #255
2026-02-26 15:03:53 +09:00
71172d738b improvement: 로그아웃 연동 완료 (#253)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/254
2026-02-26 01:24:58 +00:00
SEAN
71102e38ac improvement: 로그아웃 응답 표준화 및 단일 API 연동 완료 (#253)
- LogoutResponseDto 신규 (logged_out, redirect_to 힌트)
- LogoutAsync 반환 타입 Task → Task<LogoutResponseDto>
- AuthController Swagger 문서에 설정 화면 단일 API 사용 명시

Closes #253
2026-02-26 10:11:26 +09:00
49da5a91c8 improvement: 비밀번호 변경 보안 정책 적용 (#251)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/252
2026-02-26 01:08:16 +00:00
SEAN
f31964c92e improvement: 비밀번호 변경 보안 정책 적용 (#251)
- 비밀번호 정책 서버 검증 강화 (영대/소문자, 숫자, 특수문자 조합, 8~64자)
- 동일 비밀번호 재사용 금지 검증 추가
- 비밀번호 변경 후 세션 무효화 (Refresh Token 삭제)
- ChangePasswordResponseDto 신규 (re_login_required 힌트)
- 에러코드 추가 (PasswordPolicyViolation, PasswordReuseForbidden)
- AuthController Swagger 문서 보강

Closes #251
2026-02-26 10:07:12 +09:00
335676a282 improvement: 마이페이지 조회 확장 (#249)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/250
2026-02-26 01:03:22 +00:00
SEAN
04dd5be046 improvement: 마이페이지 조회 확장 (#249)
- Admin 엔티티에 Organization 컬럼 추가 + Migration
- ProfileResponseDto에 last_login_at, organization 필드 추가
- UpdateProfileRequestDto에 organization 필드 추가
- AuthService 프로필 조회/수정 매핑 확장
- 활동 내역 DTO 및 GetActivityListAsync 메서드 추가
- ProfileController 활동 내역 조회 엔드포인트 추가

Closes #249
2026-02-26 10:01:48 +09:00
7dcdb03796 improvement: Notification 도메인 구축 (#247)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/248
2026-02-26 00:47:32 +00:00
SEAN
c29a48163d improvement: Notification 도메인 구축 (#247)
- Domain: NotificationCategory enum, Notification entity, INotificationRepository
- Infrastructure: NotificationConfiguration, NotificationRepository, AppDbContext/DI 등록
- Migration: AddNotificationTable 생성 및 적용
- Application: DTO 7개, INotificationService, NotificationService, DI 등록
- API: NotificationController (summary, list, read, read-all)

Closes #247
2026-02-26 09:44:28 +09:00
f474b916c4 improvement: 태그 삭제 시 디바이스 orphan 참조 제거 (#186)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/246
2026-02-26 00:17:17 +00:00
SEAN
4db27aaf8a improvement: 태그 삭제 시 디바이스 orphan 참조 제거 (#186)
- IDeviceRepository에 GetDevicesByTagIdAsync 메서드 추가
- DeviceRepository에 LIKE 기반 태그 참조 디바이스 조회 구현
- TagService.DeleteAsync에서 트랜잭션으로 원자적 처리:
  디바이스 Tags JSON에서 삭제 대상 tagId 제거 후 태그 삭제
2026-02-26 09:12:41 +09:00
0ee0da4fa4 improvement: 태그 CRUD API 구현 (#186)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/245
2026-02-25 09:11:13 +00:00
SEAN
7ffc152536 improvement: 태그 CRUD API 구현 (#186)
- Tag DTO 6종 생성 (List/Create/Update/Delete Request/Response)
- ITagRepository 확장 (GetTagListAsync, CountByServiceAsync)
- IDeviceRepository 확장 (GetDeviceCountsByTagIdsAsync)
- ITagService/TagService 구현 (CRUD 비즈니스 로직)
- TagController 신규 생성 (v1/in/tag/list, create, update, delete)
- DI 등록

Closes #186
2026-02-25 18:07:11 +09:00
6b4f502bb8 improvement: Tag 테이블 신설 및 도메인 모델 확정 (#243)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/244
2026-02-25 08:41:58 +00:00
SEAN
c458cfe4e7 improvement: Tag 테이블 신설 및 도메인 모델 확정 (#243)
- Tag 엔티티 생성 (ServiceId, Name, Description, CreatedAt, CreatedBy)
- ITagRepository 인터페이스 및 TagRepository 구현
- TagConfiguration: Unique Index (ServiceId, Name), FK Restrict
- Service.TagList Navigation 추가
- ErrorCodes에 Tag 에러코드 4종 추가 (191~194)
- AppDbContext DbSet<Tag>, DI 등록
- EF Core Migration AddTagTable 생성 및 적용

Closes #243
2026-02-25 17:36:14 +09:00
ef00ea130d improvement: 기기 엑셀 내보내기 API 추가 (#241)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/242
2026-02-25 08:19:55 +00:00
SEAN
a2d563aa9d improvement: 기기 엑셀 내보내기 API 추가 (#241)
- DeviceExportRequestDto: 목록 필터와 동일한 필터 파라미터 (page/size 제외)
- IDeviceRepository/DeviceRepository: GetAllFilteredAsync 추가 (전체 반환)
- DeviceService: ClosedXML 기반 엑셀 생성 (14개 컬럼)
- DeviceController: POST /v1/in/device/export [Authorize] 엔드포인트 추가

Closes #241
2026-02-25 17:16:13 +09:00
76873e7fbc improvement: 관리자 기기 삭제/차단 API 추가 (#239)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/240
2026-02-25 08:12:47 +00:00
SEAN
48049bba9e improvement: 관리자 기기 삭제/차단 API 추가 (#239)
- IDeviceService: AdminDeleteAsync(long deviceId) 추가
- DeviceService: Device 조회 → IsActive=false → 토큰 캐시 무효화
- DeviceController: POST /v1/in/device/admin/delete [Authorize] 엔드포인트 추가
- 기존 SDK 삭제 API와 분리, JWT 인증 기반 관리자 전용

Closes #239
2026-02-25 17:06:11 +09:00
d98f8c89a4 improvement: 관리자 기기 목록 API 확장 (#237)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/238
2026-02-25 08:01:44 +00:00
SEAN
afaeb6d116 improvement: 관리자 기기 목록 API 확장 (#237)
- DeviceListRequestDto: keyword, marketing_agreed 필터 추가
- DeviceSummaryDto: 8개 응답 필드 추가 (device_token, service_name, service_code, os_version, app_version, marketing_agreed, is_active, created_at)
- DeviceRepository: keyword/marketingAgreed 필터 + Include(Service) 추가
- DeviceService: 새 필터 전달 + 응답 매핑 확장

Closes #237
2026-02-25 16:56:59 +09:00
016550e3b9 improvement: 대시보드 TopMessage에 status 필드 추가 (#193)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/236
2026-02-25 07:51:53 +00:00
SEAN
65eb9e785a improvement: 대시보드 TopMessage에 status 필드 추가 (#193)
- TopMessageDto에 status 필드 추가 (SendStatus.Determine 적용)
- 대시보드/이력 간 동일 건의 상태 라벨 일치 보장

Closes #193
2026-02-25 16:50:17 +09:00
5fc2221d5b improvement: 이력 엑셀 내보내기 API 추가 (#191)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/235
2026-02-25 07:41:48 +00:00
SEAN
3d8c57f690 improvement: 이력 엑셀 내보내기 API 추가 (#191)
- POST /v1/in/stats/history/export 엔드포인트 추가
- history/list와 동일 필터(keyword/status/date) 기준 엑셀 내보내기
- PushSendLogRepository에서 GroupBy 쿼리를 private helper로 리팩토링
- ClosedXML로 엑셀 생성 (메시지코드/제목/서비스명/발송일시/대상수/성공/실패/오픈율/상태)

Closes #191
2026-02-25 16:39:56 +09:00
9350066fb4 improvement: 이력 목록/상세 API 추가 (#233)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/234
2026-02-25 07:25:00 +00:00
SEAN
347c9aa4bf improvement: 이력 목록/상세 API 추가 (#233)
- POST /v1/in/stats/history/list: 메시지별 발송 이력 목록 조회
  (keyword/status/date 필터, 페이지네이션)
- POST /v1/in/stats/history/detail: 특정 메시지 상세 이력 조회
  (기본정보+집계+실패사유 Top 5+본문)
- SendStatus.Determine() 규칙 재사용

Closes #233
2026-02-25 16:23:11 +09:00
f33971a1d0 improvement: 대시보드 통합 API 추가 (#231)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/232
2026-02-25 07:12:24 +00:00
SEAN
ffde006e94 improvement: 대시보드 통합 API 추가 (#231)
Closes #231
2026-02-25 16:04:00 +09:00
b177557094 improvement: 통계 서비스 범위 정책 고정 (#229)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/230
2026-02-25 06:50:52 +00:00
SEAN
a3b2da5ffb improvement: 통계 서비스 범위 정책 고정 (#229)
- Stats 도메인 에러코드 추가 (171: DateRangeInvalid, 172: ServiceScopeInvalid)
- StatsService ParseDateRange에서 generic BadRequest → StatsDateRangeInvalid로 교체
- StatsController 전 엔드포인트 Swagger Description에 스코프 정책 안내 추가
- SpmsHeaderOperationFilter에서 message/list를 Optional로 반영 (미들웨어 정합)

Closes #229
2026-02-25 15:47:02 +09:00
15a2dd66e5 improvement: 메시지 발송 상태 집계 규칙 고정 (#178)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/228
2026-02-25 06:11:46 +00:00
SEAN
46a2105c13 improvement: 메시지 발송 상태 집계 규칙 고정 (#178)
Closes #178
2026-02-25 15:09:01 +09:00
a08f0a958c improvement: 메시지 상세/프리뷰 응답 강화 (#226)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/227
2026-02-25 05:51:18 +00:00
SEAN
0eacf25eb3 improvement: 메시지 상세/프리뷰 응답 강화 (#226)
- MessageInfoResponseDto에 service_name, service_code, created_by_name, latest_send_status 추가
- MessagePreviewRequestDto/ResponseDto에 JsonPropertyName snake_case 적용
- MessagePreviewResponseDto에 link_type 필드 추가
- Repository에 GetByMessageCodeWithDetailsAsync (Navigation Include), GetSendStatsAsync 추가
- MessageService.GetInfoAsync에서 서비스/작성자/발송상태 매핑
- MessageService.PreviewAsync에서 link_type 반환

Closes #226
2026-02-25 14:43:29 +09:00
d21fb7c883 improvement: 메시지 목록 확장 (#224)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/225
2026-02-25 05:31:08 +00:00
SEAN
011cb9b380 improvement: 메시지 목록 확장 (#224)
- ServiceCodeMiddleware: message/list를 OPTIONAL_FOR_ADMIN에 추가
- MessageListRequestDto: service_code, send_status 필터 필드 추가
- MessageSummaryDto: service_name, service_code, latest_send_status 추가
- IMessageRepository + MessageRepository: GetPagedForListAsync 구현
  (Service 조인 + PushSendLog 집계 한 번의 쿼리)
- IMessageService + MessageService: serviceId nullable 변경, DetermineSendStatus 헬퍼
- MessageController: GetServiceIdOrNull() 헬퍼 + Swagger 업데이트

Closes #224
2026-02-25 14:28:09 +09:00
b9b3fa2fc0 improvement: 메시지 저장/검증 계약 통일 (#222)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/223
2026-02-25 05:10:22 +00:00
SEAN
b373d59710 improvement: 메시지 저장/검증 계약 통일 (#222)
- MessageValidateRequestDto에 JsonPropertyName 추가 (snake_case 통일)
- MessageValidateRequestDto.Data 타입 string? → object? 변경
- MessageValidationService.ValidateData 파라미터 타입 변경
- Swagger Description 업데이트 (save/validate 엔드포인트)

Closes #222
2026-02-25 14:06:54 +09:00
fecd322763 improvement: API Key 마스킹 및 전체 조회 엔드포인트 추가 (#220)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/221
2026-02-25 04:58:10 +00:00
SEAN
351135549e improvement: API Key 마스킹 및 전체 조회 엔드포인트 추가 (#220)
- 상세 조회 시 API Key 마스킹 (앞 8자 + ********)
- API Key 전체 조회 엔드포인트 신규 (apikey/view)
- 기존 재발급 엔드포인트 (apikey/refresh) 유지
- Swagger Description 업데이트

Closes #220
2026-02-25 13:56:59 +09:00
c20025e181 improvement: 수정/삭제/진단 계약 확장 (#218)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/219
2026-02-25 04:38:10 +00:00
SEAN
17caeb08e2 improvement: 수정/삭제/진단 계약 확장 (#218)
- 플랫폼 자격증명 삭제 API 추가 (APNs/FCM 각각)
- 자격증명 진단 응답에 credentialStatus/statusReason 추가
- 수정 API에 Status 필드 추가 (원자적 상태 변경)
- Swagger Description 업데이트

Closes #218
2026-02-25 13:36:47 +09:00
044ebc17d0 improvement: 서비스 목록/상세 응답에 플랫폼 상태 판정 추가 (#216)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/217
2026-02-25 04:23:52 +00:00
SEAN
e3ed3d4267 improvement: 서비스 목록/상세 응답에 플랫폼 상태 판정 추가 (#216)
- PlatformSummaryDto / PlatformCredentialSummaryDto 신규 생성
- ServiceSummaryDto에 Platforms 필드 추가 (목록 응답)
- ServiceResponseDto에 ApnsAuthType + Platforms 필드 추가 (상세 응답)
- BuildPlatformSummary 메서드로 Android/iOS 상태 판정
  - Android: FcmCredentials 유무 → ok/none
  - iOS p8: → ok
  - iOS p12: 만료됨→error, 30일 이내→warn, 그 외→ok
- Swagger Description 업데이트

Closes #216
2026-02-25 13:21:30 +09:00
e50f3f186c improvement: APNs p12 인증서 지원 추가 (#214)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/215
2026-02-25 04:04:12 +00:00
SEAN
d051ff3b97 improvement: APNs p12 인증서 지원 추가 (#214)
- Service 엔티티에 ApnsAuthType/ApnsCertificate/ApnsCertPassword/ApnsCertExpiresAt 추가
- EF Core Configuration + Migration (AddApnsP12Support)
- DTO: AuthType 분기 (p8/p12) 지원, p12 필드 추가
- 서비스 로직: AuthType별 검증/저장/조회 분기, X509CertificateLoader로 만료일 추출
- AuthType 전환 시 이전 타입 필드 null 초기화
- 컨트롤러 Swagger Description 업데이트

Closes #214
2026-02-25 13:01:55 +09:00
06d2f6d023 improvement: 서비스 통합 등록 플로우 구현 (#212)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/213
2026-02-25 03:34:31 +00:00
SEAN
4916488175 improvement: 서비스 통합 등록 플로우 구현 (#212)
- POST /v1/in/service/register 통합 등록 엔드포인트 추가
- RegisterServiceRequestDto/ResponseDto 신규 생성
- 서비스 생성 + FCM/APNs 자격증명을 트랜잭션으로 원자성 보장
- 검증 로직 private 메서드 추출 (기존 코드 재사용)
- 자격증명은 선택사항, 검증 실패 시 전체 롤백

Closes #212
2026-02-25 12:26:45 +09:00
a44f023027 improvement: 서비스명 중복 확인 API 및 ID 정책 보강 (#210)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/211
2026-02-25 03:17:39 +00:00
SEAN
4577d8c10d improvement: 서비스명 중복 확인 API 및 전용 에러코드 추가 (#210)
- POST /v1/in/service/name/check 엔드포인트 추가
- ServiceNameDuplicate(134) 에러코드 추가
- CreateAsync/UpdateAsync 서비스명 중복 에러코드 변경
- CreateServiceRequestDto MinimumLength=2 검증 추가

Closes #210
2026-02-25 12:15:28 +09:00
7c9939787e improvement: 인증 보안 정책 — Rate Limit + 시도제한 + 보안 로깅 (#190)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/209
2026-02-25 02:59:52 +00:00
SEAN
42aa04f58e improvement: 인증 보안 정책 — Rate Limit + 시도제한 + 보안 로깅 (#190)
- auth_sensitive 명명 Rate Limit 정책 추가 (20회/15분/IP)
- AuthController 3개 + PasswordController 2개 메서드에 EnableRateLimiting 적용
- 로그인 시도 제한 구현 (5회/15분, Redis 카운터, LoginAttemptExceeded 에러코드 활성화)
- 비밀번호 찾기/임시 비밀번호 요청 제한 (3회/1시간, silent 반환)
- AuthService 보안 이벤트 구조적 로깅 (ILogger 주입)
- Swagger 429 응답 문서화

Closes #190
2026-02-25 11:13:49 +09:00
09831ebcbf improvement: 임시 비밀번호 발급 및 강제변경 플로우 구현 (#207)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/208
2026-02-25 01:52:51 +00:00
SEAN
3acae06ed1 improvement: 임시 비밀번호 발급 및 강제변경 플로우 구현 (#207)
- Admin 엔티티에 MustChangePassword, TempPasswordIssuedAt 필드 추가
- POST /v1/in/account/password/temp 엔드포인트 추가
- 임시비밀번호 생성(12자, 영대소+숫자+특수) 및 메일 발송
- 로그인 시 CHANGE_PASSWORD 분기 추가 (VERIFY_EMAIL > CHANGE_PASSWORD > GO_DASHBOARD)
- 비밀번호 변경 시 MustChangePassword 플래그 자동 해제
- LoginResponseDto에 must_change_password 필드 추가
- EF Core 마이그레이션 생성 및 적용

Closes #207
2026-02-25 10:51:37 +09:00
b6008fb657 improvement: 이메일 인증/재전송 강화 (#205)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/206
2026-02-25 01:40:55 +00:00
SEAN
3cc99c0284 improvement: 이메일 인증/재전송 강화 (#205)
- verify API: verifySessionId 기반 입력 지원 (email 하위호환)
- verify API: 시도 횟수 5회 제한 (30분 TTL)
- verify API: 응답에 verified, nextAction 필드 추가
- resend API 신규: POST /v1/in/auth/email/verify/resend
- resend API: 60초 쿨다운, 기존 코드 자동 무효화
- email_verify TTL 1시간→5분 변경 (signup/login 포함)
- ErrorCodes 추가: VerifyResendCooldown(116), VerifyAttemptExceeded(117)

Closes #205
2026-02-25 10:38:41 +09:00
7155fb58dc improvement: 로그인 분기 계약 확장 (#177)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/204
2026-02-25 01:21:03 +00:00
SEAN
859eabd83c improvement: 로그인 분기 계약 확장 (#177)
- LoginResponseDto에 nextAction, emailVerified, verifySessionId, emailSent 추가
- 미인증 유저 로그인 시 verify session/인증코드 생성 + 메일 발송
- Swagger Description에 분기 설명 추가

Closes #177
2026-02-25 10:08:45 +09:00
512585e7e7 improvement: 가입 계약 확장 (#202)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/203
2026-02-25 00:43:59 +00:00
SEAN
8224c7a17b improvement: 가입 계약 확장 — 동의 필드/세션/메일 발송 안정화 (#202)
- Admin 엔티티에 AgreeTerms, AgreePrivacy, AgreedAt 필드 추가
- SignupRequestDto에 동의 필드 추가 (필수 검증)
- SignupResponseDto에 verifySessionId, emailSent 응답 추가
- AuthService.SignupAsync: 동의 검증, verify session 생성, 메일 발송 try-catch
- ErrorCodes에 TermsNotAgreed(114), PrivacyNotAgreed(115) 추가
- EF Core 마이그레이션 AddConsentFieldsToAdmin 생성/적용

Closes #202
2026-02-25 09:29:17 +09:00
10460b40c3 improvement: 로그아웃 시 Access Token 즉시 무효화 (#169)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/201
2026-02-24 08:35:01 +00:00
SEAN
bf8f82e66c improvement: 로그아웃 시 Access Token 즉시 무효화 (#169)
- IJwtService/JwtService에 GetTokenInfo(JTI, 만료시간 추출) 추가
- LogoutAsync에 Redis 블랙리스트 로직 추가 (key: blacklist:{jti}, TTL: 남은 만료시간)
- AuthenticationExtensions OnTokenValidated에서 블랙리스트 체크
- 로그아웃 후 동일 Access Token 재사용 시 401 반환

Closes #169
2026-02-24 17:33:37 +09:00
68fe6b91a5 improvement: 서비스 스코프 정책 고정 (#199)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/200
2026-02-24 08:17:42 +00:00
SEAN
f04eb44fff improvement: 서비스 스코프 정책 고정 (#199)
- ErrorCodes.ServiceScopeRequired("133") 추가
- SpmsException.Forbidden 팩토리 추가
- ServiceCodeMiddleware 3-카테고리 라우팅 (SKIP/REQUIRED/OPTIONAL_FOR_ADMIN)
- Swagger 필터 stats/device-list X-Service-Code optional 표시
- StatsController/DeviceController GetOptionalServiceId() 적용
- IStatsService/IDeviceService/레포지토리 시그니처 long? serviceId 변경
- StatsService/DeviceService null serviceId 전체 서비스 모드 처리

Closes #199
2026-02-24 17:11:30 +09:00
a37e57f789 improvement: 공통 응답/에러 포맷 고정 (#164)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/198
2026-02-24 07:50:14 +00:00
SEAN
4bc08715fa improvement: 공통 응답/에러 포맷 고정 (#164)
- FieldError DTO 공통화 (SPMS.Domain/Common)
- ValidationErrorData + ApiResponse.ValidationFail() 추가
- InvalidModelStateResponseFactory로 ModelState 에러 ApiResponse 변환
- Controller Unauthorized 응답 throw SpmsException으로 통일 (에러코드 102)
- MessageValidationService ValidationErrorDto → FieldError 교체

Closes #164
2026-02-24 16:24:56 +09:00
febd6f6da0 improvement: InMemoryTokenStore를 Redis 기반으로 교체 (#162)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/163
2026-02-11 02:23:06 +00:00
SEAN
74e6bd83dc improvement: InMemoryTokenStore를 Redis 기반으로 교체 (#162)
- RedisTokenStore 구현 (ITokenStore, Redis StringSet/Get/KeyDelete)
- DI 등록 변경 (InMemoryTokenStore → RedisTokenStore)
- AddMemoryCache() 제거 (더 이상 사용처 없음)

Closes #162
2026-02-11 11:20:16 +09:00
890feb9b4c improvement: DeadTokenCleanupWorker Redis 캐시 무효화 연동 (#160)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/161
2026-02-11 02:16:04 +00:00
SEAN
a6d9f2a46f improvement: DeadTokenCleanupWorker Redis 캐시 무효화 연동 (#160)
- ITokenCacheService 주입, 배치 삭제 시 Redis 캐시 무효화
- SELECT → DELETE → 캐시 무효화 순서로 변경
- TASKS.md git 트래킹 해제 (.gitignore에 이미 등록됨)

Closes #160
2026-02-11 11:13:26 +09:00
b1cac9d08a improvement: PushWorker 웹훅 발송 연동 (#158)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/159
2026-02-11 02:08:41 +00:00
SEAN
8b1ae4dc02 improvement: PushWorker 웹훅 발송 연동 (#158)
- PushWorker에 IWebhookService 의존성 주입
- 발송 완료 후 push_sent/push_failed 이벤트 웹훅 호출
- TASKS.md API 커버리지 테이블 업데이트 (65/65 완료)

Closes #158
2026-02-11 11:07:04 +09:00
f972982b85 fix: Health check Redis 상태 실제 PING 체크로 변경 (#156)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/157
2026-02-11 01:51:56 +00:00
SEAN
c63a61bf6a fix: Health check Redis 상태를 실제 PING 체크로 변경 (#156)
- not_configured 하드코딩 제거
- RedisConnection 주입 후 PingAsync()로 실제 연결 상태 확인
- 응답에 latency 포함

Closes #156
2026-02-11 10:49:47 +09:00
bbcb770b2d feat: Redis 토큰 캐시 관리 구현 (#154)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/155
2026-02-11 01:45:16 +00:00
SEAN
1b6a87588c feat: Redis 토큰 캐시 관리 구현 (#154)
- ITokenCacheService 인터페이스 및 Redis 기반 TokenCacheService 구현
- Key: device:token:{serviceId}:{deviceId}, TTL: 1시간
- PushWorker single 발송 시 캐시 우선 조회, 미스 시 DB 조회 후 캐시 저장
- DeviceService 등록/수정/삭제/수신동의 변경 시 캐시 무효화
- RedisConnection에 GetServer() 메서드 추가 (서비스별 전체 무효화용)

Closes #154
2026-02-11 10:40:32 +09:00
fec1bf289f feat: DataRetentionWorker 구현 (#152)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/153
2026-02-11 01:34:30 +00:00
SEAN
feca00e329 feat: DataRetentionWorker 구현 (#152)
- 매일 04:00 KST 스케줄 실행
- PushSendLog/PushOpenLog: 90일 보관, WebhookLog: 30일, SystemLog: 180일
- 배치 단위 10000건씩 삭제 (트랜잭션 없음)
- SystemLog에 정리 완료 로그 기록
- DI에 AddHostedService 등록

Closes #152
2026-02-11 10:31:50 +09:00
ffdc343563 feat: DeadTokenCleanupWorker 구현 (#150)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/151
2026-02-11 01:27:46 +00:00
SEAN
ca4f278b14 feat: DeadTokenCleanupWorker 구현 (#150)
- 매주 일요일 03:00 KST 스케줄 실행
- is_active=false, updated_at 7일 이전 Device 물리 삭제
- 배치 단위 1000건씩 삭제 (트랜잭션 없음)
- 안전장치: 전체의 50% 초과 시 삭제 중단 + 경고 로그
- SystemLog에 정리 완료/안전장치 발동 로그 기록
- DI에 AddHostedService 등록

Closes #150
2026-02-11 10:26:00 +09:00
17b9f14372 feat: DailyStatWorker 구현 (#148)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/149
2026-02-11 01:19:22 +00:00
SEAN
87b48441cb feat: DailyStatWorker 구현 (#148)
Closes #148
2026-02-11 10:17:06 +09:00
9ab7d4786d feat: 웹훅 발송 서비스 구현 (#146)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/147
2026-02-11 01:13:04 +00:00
SEAN
d717603365 feat: 웹훅 발송 서비스 구현 (#146)
Closes #146
2026-02-11 10:10:11 +09:00
b5de3ca2d1 feat: 웹훅 설정 API 구현 (#144)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/145
2026-02-11 01:06:25 +00:00
SEAN
2aa676d60f feat: 웹훅 설정 API 구현 (#144)
Closes #144
2026-02-11 10:03:03 +09:00
5890392121 feat: 실패원인 순위 API 구현 (#142)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/143
2026-02-11 00:54:16 +00:00
SEAN
a5b8bda162 feat: 실패원인 순위 API 구현 (#142)
Closes #142
2026-02-11 09:51:44 +09:00
519569ab72 feat: 상세 로그 다운로드 API 구현 (#140)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/141
2026-02-11 00:47:04 +00:00
SEAN
3a973f56ce feat: 상세 로그 다운로드 API 구현 (#140)
- POST /v1/in/push/log/export (EXP-02, API_SPMS_07_PUSH_09)
- 발송 로그 CSV 파일 다운로드 (페이징 없이 전체 반환)
- 최대 조회 기간 30일, 최대 100,000건 제한
- message_code, device_id, status 필터 지원

Closes #140
2026-02-11 09:43:47 +09:00
042e6e1dd6 feat: 통계 리포트 다운로드 API 구현 (#138)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/139
2026-02-11 00:41:02 +00:00
SEAN
d3bd4356a8 feat: 통계 리포트 다운로드 API 구현 (#138)
- POST /v1/in/stats/export (EXP-01)
- 일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 3시트로 생성
- ClosedXML 패키지 추가

Closes #138
2026-02-11 09:38:46 +09:00
ce266956c7 feat: 발송 상세 로그 조회 API 구현 (#136)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/137
2026-02-11 00:33:00 +00:00
SEAN
bcc40b4c01 feat: 발송 상세 로그 조회 API 구현 (#136)
- POST /v1/in/stats/send-log (DDL-02)
- 특정 메시지의 개별 디바이스별 발송 로그 조회
- 플랫폼(iOS/Android/Web), 성공/실패 필터 지원
- Device Include로 디바이스 토큰, 플랫폼 정보 포함

Closes #136
2026-02-11 09:29:03 +09:00
86f978633e feat: 운영자 관리 API 구현 (#134)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/135
2026-02-11 00:20:11 +00:00
SEAN
3e5aeacd5e feat: 운영자 관리 API 구현 (#134)
- POST /v1/in/account/operator/create (계정 생성 + 비밀번호 설정 이메일)
- POST /v1/in/account/operator/delete (Soft Delete, 자기 자신 삭제 방지)
- POST /v1/in/account/operator/list (페이징 + role/is_active 필터)
- POST /v1/in/account/operator/password/reset (비밀번호 초기화 + 세션 무효화)

Closes #134
2026-02-11 09:16:04 +09:00
e0f7d422c3 feat: 통계 API 구현 (8.1~8.5) (#132)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/133
2026-02-10 14:11:26 +00:00
caa2148654 chore: TASKS.md 통계 API 완료 표시 (#132)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:09:41 +09:00
0911fc763a feat: 통계 API 구현 (8.1~8.5) (#132)
- POST /v1/in/stats/daily: 기간별 일별 통계
- POST /v1/in/stats/summary: 대시보드 요약 통계
- POST /v1/in/stats/message: 메시지별 발송 통계
- POST /v1/in/stats/hourly: 시간대별 발송 추이
- POST /v1/in/stats/device: 디바이스 분포 통계
- IDailyStatRepository, DailyStatRepository 신규
- IPushSendLogRepository 통계 메서드 확장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:08:04 +09:00
d940948df0 feat: 대용량 발송/상태조회/취소 API 구현 (#130)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/131
2026-02-10 14:00:25 +00:00
4298171d61 chore: TASKS.md 대용량 발송 API 완료 표시 (#130)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:56:01 +09:00
830cbf2edc feat: 대용량 발송/상태조회/취소 API 구현 (#130)
- POST /v1/in/push/send/bulk: CSV 대량 발송 (비동기)
- POST /v1/in/push/job/status: Job 상태 조회
- POST /v1/in/push/job/cancel: Job 취소
- BulkJobStore: Redis Hash 기반 Job 상태 관리
- PushWorker: Job 진행률 추적 및 취소 체크

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:55:39 +09:00
dc487609b3 feat: 메시지 CRUD API 구현 (#128)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/129
2026-02-10 13:43:28 +00:00
01c8f7602f chore: TASKS.md Phase 3 재정비 - API Spec 기준 (#128)
- API_Specification.md 기준으로 Phase 3 테이블 재구성
- 미구현 API 7개 추가 (Message 6.1~6.4, Push 7.3~7.5)
- Feature_Spec 전용 항목(딥링크/재발송) 보류 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:38:50 +09:00
fc7ab51fa3 feat: 메시지 CRUD API 구현 (#128)
- 메시지 저장 API (POST /v1/in/message/save)
- 메시지 목록 조회 API (POST /v1/in/message/list)
- 메시지 상세 조회 API (POST /v1/in/message/info)
- 메시지 삭제 API (POST /v1/in/message/delete)
- message_code 자동 생성 (접두3+순번4+접미3)
- 변수 추출 ({{변수명}} 패턴)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:38:24 +09:00
f8eb938a9d fix: Health check 503 응답에 상세 데이터 포함 (#126)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/127
2026-02-10 10:35:01 +00:00
f70d8a8558 fix: Health check 503 응답에 상세 데이터 포함 (#126)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:27:44 +09:00
01cc3adea4 fix: RabbitMQ 연결 실패 시 앱 크래시 방지 (#124)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/125
2026-02-10 10:16:30 +00:00
3fc3bb8144 fix: RabbitMQ 상태 모니터링 및 백그라운드 재시도 추가 (#124)
- RabbitMQInitializer를 BackgroundService로 변경 (30초 간격 재시도)
- RabbitMQConnection에 IsConnected 속성 추가
- Health check에 RabbitMQ 연결/초기화 상태 반영
- DI 등록 변경 (Singleton + HostedService 패턴)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:15:42 +09:00
efd6615809 fix: RabbitMQ 연결 실패 시 앱 크래시 방지 (#124)
- StartAsync에서 throw 제거, LogWarning으로 변경
- InitializeAsync 메서드 분리 (재시도 가능)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:06:58 +09:00
355d3269c0 feat: 발송 로그 조회 API 구현 (#122)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/123
2026-02-10 10:05:50 +00:00
SEAN
b56170f10c feat: 발송 로그 조회 API 구현 (#122)
- POST /v1/in/push/log 엔드포인트 추가
- PushSendLogRepository (페이징, 필터링: message_code, device_id, status, 날짜범위)
- PushService.GetLogAsync 구현
- 누락된 Push DTO 파일 포함 (PushSendRequestDto, PushSendResponseDto, PushSendTagRequestDto)
2026-02-10 17:41:38 +09:00
975ed77d18 feat: 메시지 미리보기 API 구현 (#120)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/121
2026-02-10 08:31:19 +00:00
SEAN
ef6d71a921 feat: 메시지 미리보기 API 구현 (#120)
- POST /v1/in/message/preview 엔드포인트 추가
- MessageService: 메시지 조회 → 변수 치환 → 미리보기 데이터 반환
- IMessageService 인터페이스 정의 (향후 CRUD 확장용)

Closes #120
2026-02-10 17:27:56 +09:00
6f58633de9 feat: 메시지 유효성 검사 서비스 구현 (#118)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
2026-02-10 17:23:12 +09:00
SEAN
ce7b8b3d35 feat: 메시지 유효성 검사 서비스 구현 (#118)
- MessageValidationService: title/body/image_url/link_url/link_type/data 검증
- POST /v1/in/message/validate 엔드포인트 추가
- MessageController 기반 구성

Closes #118
2026-02-10 17:15:57 +09:00
fc16884d25 feat: 예약 발송 등록/취소 API 구현 (#116)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/117
2026-02-10 08:11:04 +00:00
SEAN
fe1dcd0176 feat: 예약 발송 등록/취소 API 구현 (#116)
- POST /v1/in/push/schedule (예약 발송 등록)
- POST /v1/in/push/schedule/cancel (예약 취소)
- ScheduleCancelStore: Redis 기반 예약 취소 추적
- ScheduleWorker: 취소된 예약 메시지 ACK 후 스킵 로직 추가

Closes #116
2026-02-10 17:06:04 +09:00
47dff6b2f0 feat: 즉시 발송 요청 API 구현 (#114)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/115
2026-02-10 07:48:59 +00:00
SEAN
73de5efd84 feat: 즉시 발송 요청 API 구현 (#114)
- POST /v1/in/push/send (단건 발송)
- POST /v1/in/push/send/tag (태그 발송)
- PushService: 메시지 조회 → 변수 치환 → RabbitMQ 큐 발행
- MessageNotFound(151) 에러 코드 추가

Closes #114
2026-02-10 16:38:51 +09:00
b5d6c70b16 feat: ScheduleWorker 구현 (#112)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/113
2026-02-10 07:27:01 +00:00
SEAN
814d2082cb feat: ScheduleWorker 구현 (#112) 2026-02-10 16:25:25 +09:00
a11750db93 feat: PushWorker 구현 (#110)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/111
2026-02-10 07:18:09 +00:00
SEAN
f36f8f47a9 feat: PushWorker 구현 (#110) 2026-02-10 16:14:47 +09:00
81c3eee1f3 feat: Redis 중복 발송 방지 구현 (#108)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/109
2026-02-10 07:09:17 +00:00
SEAN
4292f57ad1 feat: Redis 중복 발송 방지 구현 (#108) 2026-02-10 16:04:51 +09:00
639069972b feat: APNs 발송 모듈 구현 (#106)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/107
2026-02-10 07:00:04 +00:00
SEAN
3a5c2a5b5b feat: APNs 발송 모듈 구현 (#106) 2026-02-10 15:52:26 +09:00
e2cf76fd11 feat: FCM 발송 모듈 구현 (#104)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/105
2026-02-10 06:48:26 +00:00
SEAN
7a250847f4 feat: FCM 발송 모듈 구현 (#104) 2026-02-10 15:44:32 +09:00
08aa74138f feat: RabbitMQ 인프라 설정 (Exchange/Queue) (#102)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/103
2026-02-10 06:39:31 +00:00
SEAN
4f38e31710 feat: RabbitMQ 인프라 설정 (Exchange/Queue) (#102) 2026-02-10 15:34:15 +09:00
1cae5c3754 feat: CSV 검증/템플릿 다운로드 API 구현 (#100)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/101
2026-02-10 06:24:06 +00:00
SEAN
0db4a8824d feat: CSV 검증/템플릿 다운로드 API 구현 (#100) 2026-02-10 15:22:12 +09:00
b661fb5a08 feat: 파일 업로드/조회/삭제 API 구현 (#98)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/99
2026-02-10 06:15:15 +00:00
SEAN
658fa1d63d feat: 파일 업로드/조회/삭제 API 구현 (#98) 2026-02-10 15:03:24 +09:00
314df2e664 feat: 디바이스 태그/동의 설정 API 구현 (#96)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/97
2026-02-10 05:50:57 +00:00
SEAN
e355c8be62 feat: 디바이스 태그/동의 설정 API 구현 (#96) 2026-02-10 14:49:07 +09:00
2514f82ad1 feat: 디바이스 CRUD + 목록 API 구현 (#94)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/95
2026-02-10 05:46:24 +00:00
SEAN
e9bcd5358f feat: 디바이스 CRUD + 목록 API 구현 (#94) 2026-02-10 14:44:16 +09:00
7db6099cbe feat: 점검 안내 API 구현 (#92)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/93
2026-02-10 05:36:02 +00:00
SEAN
2037a409ef feat: 점검 안내 API 구현 (#92) 2026-02-10 14:33:32 +09:00
31df012976 fix: Public API에서 X-Service-Code 의존성 제거 (#90)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/91
2026-02-10 05:29:47 +00:00
SEAN
57fdfdea0e fix: Public API에서 X-Service-Code 의존성 제거 (#90) 2026-02-10 14:26:41 +09:00
14b76d0dbd feat: 앱 기본 설정 API 구현 (#88)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/89
2026-02-10 05:21:59 +00:00
SEAN
7485e139cd feat: 앱 기본 설정 API 구현 (#88) 2026-02-10 14:17:22 +09:00
f07beda6c9 feat: 앱 버전 체크 API 구현 (#86)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/87
2026-02-10 05:14:48 +00:00
SEAN
0d80b8c25f feat: 앱 버전 체크 API 구현 (#86) 2026-02-10 14:11:28 +09:00
bd4d6424c4 feat: 이용약관/개인정보처리방침 API 구현 (#84)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/85
2026-02-10 05:08:59 +00:00
SEAN
6475c0c753 feat: 이용약관/개인정보처리방침 API 구현 (#84) 2026-02-10 14:07:13 +09:00
0ead7a4bcb feat: FAQ 목록 API 구현 (#82)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/83
2026-02-10 05:00:04 +00:00
SEAN
e24e0c2398 feat: FAQ 목록 API 구현 (#82) 2026-02-10 13:57:17 +09:00
8e52802bbf feat: 배너 목록 API 구현 (#80)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/81
2026-02-10 04:43:07 +00:00
SEAN
ad1bf2e4e6 feat: 배너 목록 API 구현 (#80) 2026-02-10 13:41:19 +09:00
f5a1afc6b3 feat: 공지사항 목록/상세 API 구현 (#78)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/79
2026-02-10 04:38:01 +00:00
SEAN
f0d325fda9 feat: 공지사항 목록/상세 API 구현 (#78) 2026-02-10 13:34:36 +09:00
c8c9a44b0f feat: Public API Entity 정의 및 DB 스키마 구축 (#76)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/77
2026-02-10 04:25:42 +00:00
SEAN
1aab5032db feat: Public API Entity 정의 및 DB 스키마 구축 (#76) 2026-02-10 13:21:15 +09:00
59c833e7f7 improvement: Message Entity link_type 컬럼 추가 (#74)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/75
2026-02-10 04:14:46 +00:00
SEAN
aea0d358e8 improvement: Message Entity link_type 컬럼 추가 (#74) 2026-02-10 13:11:29 +09:00
d821acb9a1 chore: DB 스키마 문서 동기화 + Admin EF 설정 보완 (#72)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/73
2026-02-10 03:14:52 +00:00
SEAN
473fe525f3 chore: Admin EF Configuration에 RefreshToken 명시적 설정 추가 (#72) 2026-02-10 11:39:30 +09:00
e288f28123 feat: 서비스 태그 목록/수정 API 구현 (#70)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/71
2026-02-10 02:30:56 +00:00
SEAN
6879d5e1fd feat: 서비스 태그 목록/수정 API 구현 (#70)
- ServiceTagsRequestDto, UpdateServiceTagsRequestDto, ServiceTagsResponseDto 생성
- IServiceManagementService에 GetTagsAsync, UpdateTagsAsync 추가
- ServiceManagementService에 태그 JSON 파싱/직렬화 로직 구현
- ServiceController에 POST tags/list, tags/update 엔드포인트 추가
- 태그 최대 10개 제한, 변경 없음 감지

Closes #70
2026-02-10 11:27:37 +09:00
cc10378efa feat: 서비스 삭제 API 구현 (#68)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/69
2026-02-10 02:21:25 +00:00
SEAN
65f2f914e7 feat: 서비스 삭제 API 구현 (#68)
- DeleteServiceRequestDto 생성 (service_code 필수)
- IServiceManagementService에 DeleteAsync 메서드 추가
- ServiceManagementService에 Soft Delete 로직 구현
  (IsDeleted=true, DeletedAt=UtcNow, Status=Suspended)
- ServiceController에 POST /v1/in/service/delete 엔드포인트 추가

Closes #68
2026-02-10 11:18:07 +09:00
808a42de14 feat: 비밀번호 찾기/재설정 API 구현 (#66)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/67
2026-02-10 02:08:00 +00:00
SEAN
e78f8e7c85 merge: develop 머지 충돌 해결 (Profile + EmailVerify + PasswordReset 통합) 2026-02-10 11:07:08 +09:00
8770fee529 feat: 이메일 인증 인프라 및 API 구현 (#64)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/65
2026-02-10 02:03:44 +00:00
SEAN
59f5dbf839 merge: develop 머지 충돌 해결 (Profile + EmailVerify 통합) 2026-02-10 11:02:47 +09:00
5d22fed32a feat: 내 정보 조회/수정 API 구현 (#62)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/63
2026-02-10 01:59:36 +00:00
SEAN
5f25614e53 feat: 비밀번호 찾기/재설정 API 구현 (#66)
- PasswordForgotRequestDto, PasswordResetRequestDto 생성
- IAuthService에 ForgotPasswordAsync, ResetPasswordAsync 추가
- ForgotPasswordAsync: UUID 토큰 생성 → ITokenStore 저장(30분) → 이메일 발송
- ResetPasswordAsync: 토큰 검증 → BCrypt 해싱 → 비밀번호 저장
- PasswordController 생성 (v1/in/account/password)
- 보안: forgot에서 이메일 미존재 시에도 동일한 성공 응답
2026-02-10 10:56:35 +09:00
SEAN
ccdfcbd62e feat: 이메일 인증 인프라 및 API 구현 (#64)
- ITokenStore, IEmailService 인터페이스 정의
- InMemoryTokenStore (IMemoryCache 기반), ConsoleEmailService (로그 출력) 구현
- SignupAsync에 6자리 인증 코드 생성/저장/발송 로직 추가
- VerifyEmailAsync 구현 (코드 검증 → EmailVerified 업데이트)
- POST /v1/in/auth/email/verify 엔드포인트 추가
- DI 등록 (ITokenStore, IEmailService, MemoryCache)
2026-02-10 10:52:47 +09:00
SEAN
e81b12fbea feat: 내 정보 조회/수정 API 구현 (#62)
- ProfileResponseDto, UpdateProfileRequestDto 생성
- IAuthService에 GetProfileAsync, UpdateProfileAsync 추가
- AuthService에 프로필 조회/수정 로직 구현
- ProfileController 생성 (v1/in/account/profile)
2026-02-10 10:48:57 +09:00
e96bbff727 feat: 이메일 중복 체크 API 구현 (#58)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/61
2026-02-10 01:27:57 +00:00
f0761a15ca Merge branch 'develop' into feature/#58-email-check-api 2026-02-10 01:27:30 +00:00
179e5897bf fix: X-Service-Code 미들웨어 경로 제외 수정 (#59)'
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/60
2026-02-10 01:26:36 +00:00
SEAN
5d49a2ce49 feat: 이메일 중복 체크 API 구현 (#58)
POST /v1/in/auth/email/check 엔드포인트 추가.
기존 EmailExistsAsync 활용하여 이메일 사용 가능 여부 반환.
2026-02-10 10:23:25 +09:00
SEAN
f798b290ec fix: X-Service-Code 미들웨어 경로 제외 수정 (#59)
auth, account, public, service 경로를 X-Service-Code 검증 대상에서 제외.
device, message, push, stats, file 경로만 X-Service-Code 헤더 필요.
Swagger OperationFilter도 동일하게 수정.
2026-02-10 10:20:43 +09:00
94b0787bf8 feat: 회원가입 API 구현 (#56)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/57
2026-02-10 01:06:59 +00:00
SEAN
16550dbff3 feat: 회원가입 API 구현 (#56)
- POST /v1/in/auth/signup 엔드포인트 추가 (AllowAnonymous)
- SignupRequestDto/SignupResponseDto 생성
- AuthService.SignupAsync 구현 (이메일 중복검사, AdminCode 생성, BCrypt 해싱)
- ApiResponse<T>.Success(data, msg) 오버로드 추가
2026-02-10 10:04:58 +09:00
b5b015255e feat: 서비스 수정 API 구현 (#54)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/55
2026-02-10 00:57:44 +00:00
SEAN
e1bab0cce6 feat: 서비스 수정 API 구현 (#54) 2026-02-10 09:44:36 +09:00
de679f6f7e feat: 서비스 등록 API 구현 (#52)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/53
2026-02-10 00:41:50 +00:00
SEAN
21dc6e608c feat: 서비스 등록 API 구현 (#52) 2026-02-10 09:38:56 +09:00
9762052dd6 feat: IP 화이트리스트 관리 API 구현 (#50)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/51
2026-02-09 15:55:01 +00:00
c8a3d616c3 feat: IP 화이트리스트 관리 API 구현 (#50)
- IP 목록 조회, 추가, 삭제 API 구현
- IPv4 형식 검증 추가
- 중복 IP 체크 로직 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 00:52:41 +09:00
e335f762bd feat: APNs/FCM 키 등록 및 조회 API 구현 (#48)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/49
2026-02-09 15:33:07 +00:00
94e0b92780 feat: APNs/FCM 키 등록 및 조회 API 구현 (#48)
- APNs 키 등록 API (POST /v1/in/service/{serviceCode}/apns)
- FCM 키 등록 API (POST /v1/in/service/{serviceCode}/fcm)
- 키 정보 조회 API (POST /v1/in/service/{serviceCode}/credentials)
- AES-256 암호화로 민감 정보 저장
- 조회 시 메타 정보만 반환 (Private Key 미노출)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 00:28:47 +09:00
3b4b1873a3 feat: API Key 재발급 API 구현 (#46)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/47
2026-02-09 15:16:21 +00:00
4cb54e4c41 feat: API Key 재발급 API 구현 (#46)
- ApiKeyRefreshResponseDto 추가
- IServiceManagementService.RefreshApiKeyAsync 인터페이스 추가
- ServiceManagementService.RefreshApiKeyAsync 구현 (32~64자 랜덤 키 생성)
- ServiceController.RefreshApiKeyAsync 엔드포인트 추가
2026-02-10 00:13:16 +09:00
3d793c652b feat: 서비스 관리 API 구현 (#44)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/45
2026-02-09 15:04:05 +00:00
cac56761f4 feat: 서비스 관리 API 구현 (#44)
- ServiceController: 서비스 목록/상세/상태변경 엔드포인트
- ServiceManagementService: 비즈니스 로직 구현
- Service DTOs: 요청/응답 DTO 4종
- DI 등록

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 00:01:33 +09:00
b58662b520 feat: 운영자 계정 CRUD API 구현 (#42)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/43
2026-02-09 14:54:42 +00:00
b6939c0fa9 feat: 운영자 계정 CRUD API 구현 (#42)
- AccountController: 운영자 CRUD 엔드포인트 (create, list, detail, update, delete)
- AccountService: 비즈니스 로직 구현
- Account DTOs: 요청/응답 DTO 5종
- ErrorCodes: Forbidden 코드 추가
- DI 등록

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 23:51:04 +09:00
e0bf0adf70 feat: 관리자 비밀번호 변경 API 구현 (#40)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/41
2026-02-09 14:24:13 +00:00
9b9ca64b10 feat: 관리자 비밀번호 변경 API 구현 (#40)
- ChangePasswordRequestDto 추가
- IAuthService/AuthService에 ChangePasswordAsync 구현
- AuthController에 POST /v1/in/auth/password/change 엔드포인트 추가
- 현재 비밀번호 검증 및 BCrypt 해싱 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 23:22:28 +09:00
5eb3635719 feat: 토큰 갱신 및 로그아웃 API 구현 (#38)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/39
2026-02-09 14:17:46 +00:00
336dcf8193 feat: 토큰 갱신 및 로그아웃 API 구현 (#38)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 23:08:29 +09:00
f037977102 feat: 관리자 로그인 API 구현 (#36)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/37
2026-02-09 13:53:43 +00:00
b11c8dc918 feat: 관리자 로그인 API 구현 (#36)
- LoginRequestDto, LoginResponseDto 추가
- IAuthService, AuthService 구현 (BCrypt 비밀번호 검증)
- AdminRepository 구현 (GetByEmailAsync)
- AuthController 추가 (POST /v1/in/auth/login)
- DI 등록 (IAuthService, IAdminRepository)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 22:16:25 +09:00
0ccef1e10f feat: Sandbox 모드 미들웨어 구현 (#34)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/35
2026-02-09 08:33:40 +00:00
SEAN
9185afd5e9 feat: Sandbox 모드 미들웨어 구현 (#34)
- SandboxMiddleware 추가: X-SPMS-TEST 헤더로 테스트 모드 감지
- HttpContext.Items["IsSandbox"] 플래그 설정
- 미들웨어 파이프라인 14번 위치에 등록

Closes #34
2026-02-09 17:32:04 +09:00
c49807c985 feat: X-Service-Code / X-API-KEY 서비스 식별 미들웨어 구현 (#32)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/33
2026-02-09 08:26:51 +00:00
SEAN
df8a8e2e5b feat: X-Service-Code / X-API-KEY 서비스 식별 미들웨어 구현 (#32)
- ServiceRepository: IServiceRepository 구현 (GetByServiceCode, GetByApiKey)
- ServiceCodeMiddleware: X-Service-Code 헤더 검증, DB 조회, 서비스 상태 확인
- ApiKeyMiddleware: /v1/in/device/* 경로 X-API-KEY 검증
- ApplicationBuilderExtensions: 미들웨어 파이프라인 12~13번 등록
- DependencyInjection: IServiceRepository DI 등록

Closes #32
2026-02-09 17:25:19 +09:00
4270e70f09 feat: API Rate Limiting 및 Swagger UI 구현 (#30)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/31
2026-02-09 08:13:30 +00:00
SEAN
58b94c6298 feat: API Rate Limiting 및 Swagger UI 구현 (#30)
- ASP.NET Core 내장 Rate Limiting (FixedWindow, IP 기반 분당 100회)
- 한도 초과 시 HTTP 429 + ApiResponse(에러코드 106) 반환
- Swashbuckle.AspNetCore 6.9.0 기반 Swagger UI 추가
- 도메인별 API 문서 그룹 (all, public, auth 등 10개)
- JWT Bearer 인증 UI (Authorize 버튼)
- X-Service-Code/X-API-KEY 커스텀 헤더 자동 표시 필터
- Microsoft.AspNetCore.OpenApi 제거 (Swashbuckle과 호환 충돌)

Closes #30
2026-02-09 17:11:46 +09:00
fb0da8669d feat: E2EE 암호화 유틸리티 구현 (#28)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/29
2026-02-09 07:36:55 +00:00
SEAN
27f33f809b feat: E2EE 암호화 유틸리티 구현 (#28)
- AesEncryption: AES-256-CBC 암호화/복호화
- RsaEncryption: RSA-2048 키 쌍 생성/암복호화
- E2EEService: 하이브리드 암복호화 (요청 복호화, 응답 암호화)
- TimestampValidator: 타임스탬프 검증 (±30초)
- SecureTransportAttribute: Action Filter (보안등급 3 엔드포인트용)
- DI 등록: IE2EEService → E2EEService (Singleton)

Closes #28
2026-02-09 16:33:38 +09:00
0070ae58b9 feat: DI 컨테이너 및 서비스 등록 구조화 (#26)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/27
2026-02-09 07:28:03 +00:00
SEAN
cd8270c5c0 feat: DI 컨테이너 및 서비스 등록 구조화 (#26)
- Infrastructure/DependencyInjection.cs: AddInfrastructure() 확장 메서드
- Application/DependencyInjection.cs: AddApplication() 확장 메서드
- API/Extensions/ApplicationBuilderExtensions.cs: UseMiddlewarePipeline() 확장 메서드
- Program.cs 정리 (DI/파이프라인 분리)

Closes #26
2026-02-09 16:25:44 +09:00
SEAN
3125726e2c fix: Development 환경에서 UseHttpsRedirection 비활성화 (#24)
All checks were successful
SPMS_API/pipeline/head This commit looks good
2026-02-09 15:33:56 +09:00
SEAN
65b4207f94 revert: Health Check GET 메서드 제거, POST만 유지 (#24)
All checks were successful
SPMS_API/pipeline/head This commit looks good
2026-02-09 15:28:04 +09:00
SEAN
9107726c3b fix: Health Check 엔드포인트에 GET 메서드 추가 (#24)
All checks were successful
SPMS_API/pipeline/head This commit looks good
2026-02-09 15:26:34 +09:00
3f439e4d4e feat: Health Check 엔드포인트 구현 (#24)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/25
2026-02-09 06:20:49 +00:00
SEAN
a9c944a27d feat: Health Check 엔드포인트 구현 (#24)
- PublicController 생성 (POST /v1/out/health)
- MariaDB 연결 확인 (SELECT 1)
- Redis, RabbitMQ 연결 확인 (Phase 2에서 구현 예정, not_configured 상태)
- 정상: HTTP 200 + ApiResponse.Success / 이상: HTTP 503 + 상세 상태
2026-02-09 15:17:45 +09:00
9bd39669f3 feat: Serilog 구조적 로깅 설정 (#22)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/23
2026-02-09 06:13:10 +00:00
SEAN
d6b15c3cd8 feat: Serilog 구조적 로깅 설정 (#22)
- appsettings.json/Development.json에 Serilog 섹션 추가 (Console/File Sink, Rolling Daily)
- RequestIdMiddleware 구현 (X-Request-ID 헤더 발급/반환)
- Program.cs에 Serilog 호스트 빌더 + UseSerilogRequestLogging 등록
- 환경별 로그 레벨 분리 (Development: Debug, Production: Warning)
2026-02-09 15:10:17 +09:00
f3714ff8fb feat: JWT 인증 모듈 구현 (#20)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/21
2026-02-09 06:05:11 +00:00
SEAN
2d30aaf212 feat: JWT 인증 모듈 구현 (#20)
- IJwtService 인터페이스 (Application Layer)
- JwtSettings POCO (Options Pattern)
- JwtService 구현 (Access Token 생성/검증, Refresh Token 생성)
- AddJwtAuthentication/AddAuthorizationPolicies 확장 메서드
- Program.cs에 인증/인가 미들웨어 등록 (파이프라인 순서 10~11번)
- NuGet: System.IdentityModel.Tokens.Jwt, Microsoft.AspNetCore.Authentication.JwtBearer
2026-02-09 14:59:36 +09:00
24e1ccbfef feat: Generic Repository 및 UnitOfWork 패턴 구현 (#18)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/19
2026-02-09 05:47:19 +00:00
SEAN
787190f512 feat: Generic Repository 및 UnitOfWork 패턴 구현 (#18)
- IRepository<T> 구현체: CRUD, 페이징, 조건 검색 지원
- IUnitOfWork 구현체: 트랜잭션 관리 (Begin/Commit/Rollback)
- Program.cs에 DI 등록 (AddScoped)

Closes #18
2026-02-09 14:44:31 +09:00
3293c38360 feat: SpmsException 및 글로벌 예외 처리 미들웨어 구현 (#16)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/17
2026-02-09 05:37:59 +00:00
SEAN
a9f6a436c4 feat: SpmsException 및 글로벌 예외 처리 미들웨어 구현 (#16) 2026-02-09 14:33:37 +09:00
f380b348a9 feat: ApiResponse<T> 공통 응답 포맷 구현 (#14)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/15
2026-02-09 05:27:31 +00:00
SEAN
4f0a4b9bf9 feat: ApiResponse<T> 공통 응답 포맷 구현 (#14) 2026-02-09 13:57:37 +09:00
5d8e30494e feat: Domain Interface 정의 — Repository, UnitOfWork (#12)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/13
2026-02-09 04:51:52 +00:00
SEAN
da2001c79b feat: Domain Interface 정의 — Repository, UnitOfWork (#12)
IRepository<T> Generic CRUD, IUnitOfWork 트랜잭션,
도메인별 특화 Repository 인터페이스 7종 정의
(Service, Admin, Device, Message, PushLog, File, Stat)
2026-02-09 13:46:06 +09:00
c387aa4465 feat: Domain Enum 및 에러 코드 상수 정의 (#10)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/11
2026-02-09 04:35:51 +00:00
SEAN
8b6fd84b98 feat: Domain Enum 및 에러 코드 상수 정의 (#10)
- 12개 Enum 추가 (Platform, ServiceStatus, SubTier, AdminRole, DeviceStatus, MessageStatus, PushResult, PaymentStatus, TargetType, LinkType, WebhookEvent, WebhookStatus)
- ErrorCodes 상수 클래스 추가 (Error_Codes.md 기반 3자리 코드)
- Entity byte 필드를 Enum 타입으로 변경 (Service, Admin, Device, PushSendLog, WebhookLog, Payment)
- WebhookLog.EventType 컬럼 타입 변경 (varchar→tinyint) 마이그레이션 적용
2026-02-09 13:31:50 +09:00
41c9667e5a feat: Domain Entity 정의 및 DB 스키마 구축 (#8)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/9
2026-02-09 04:03:02 +00:00
SEAN
cfce5ca8b8 feat: Domain Entity 정의 및 DB 스키마 구축 (#8)
- 12개 Domain Entity 클래스 정의 (DB_Schema.md 기반)
- 12개 EF Core Fluent API Configuration 구현
- AppDbContext에 DbSet 12개 등록 및 Configuration 자동 적용
- Soft Delete 글로벌 쿼리 필터 적용 (Service, Admin, Message)
- DesignTimeDbContextFactory 추가 (EF Core CLI 지원)
- InitialCreate 마이그레이션 생성 및 spms_dev DB 적용 완료
- .gitignore에 Documents/, CLAUDE.md, TASKS.md, .mcp.json 추가

Closes #8
2026-02-09 11:46:33 +09:00
395 changed files with 36375 additions and 667 deletions

9
.gitignore vendored
View File

@ -56,4 +56,11 @@ Dockerfile
# 기타 캐시 파일
**/*.cache
**/*.tmp
**/*.tmp
# 프로젝트 문서 및 AI 설정
Documents/
CLAUDE.md
TASKS.md
TODO.md
.mcp.json

View 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;
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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;
}
}

View 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));
}
}

View 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;
}
}

View 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));
}
}

View 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;
}
}

View 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));
}
}

View 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;
}
}

View 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());
}
}

View 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));
}
}

View 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
});
}
}

View 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);
}
}

View 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());
}
}

View 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;
}
}

View 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;
}
}

View 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

View File

@ -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

View File

@ -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>
- 클린 아키텍쳐 샘플 이미지
![CleanArchitecture.png](CleanArchitecture.png)
## 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
```

View File

@ -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)' 필드에 따라 자동으로 카드가 이동하므로, 별도의 보드 관리가 필요 없다.

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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");
}
}
}

View 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" }
});
}
}
}

View 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");
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
WebRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_WEBROOT")
WebRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_WEBROOT")
?? "wwwroot"
});
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// ===== 1. Serilog =====
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 =>
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
// ===== 3. Presentation =====
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();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
// ===== 5. DB 마이그레이션 자동 적용 =====
using (var scope = app.Services.CreateScope())
{
app.MapOpenApi();
}
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!");
var db = scope.ServiceProvider.GetRequiredService<SPMS.Infrastructure.AppDbContext>();
await db.Database.MigrateAsync();
}
app.UseHttpsRedirection();
app.UseRouting();
// ===== 6. Middleware Pipeline =====
app.UseMiddlewarePipeline();
// [4] 요청 처리
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
app.Run();

View File

@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<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">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -18,6 +18,8 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.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>

View File

@ -1,10 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": ""
@ -22,5 +16,30 @@
"UserName": "",
"Password": "",
"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}"
}
}
]
}
}

View File

@ -1,10 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": ""
@ -13,13 +7,58 @@
"SecretKey": "",
"Issuer": "SPMS",
"Audience": "SPMS",
"ExpiryMinutes": 10
"ExpiryMinutes": 10,
"RefreshTokenExpiryDays": 7
},
"RabbitMQ": {
"HostName": "",
"Port": 0,
"UserName": "",
"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"
}
}
}

View 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; }
}

View 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);
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View File

@ -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;
}

View 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;
}

View 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;
}

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View 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;
}

View 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; }
}

View 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;
}

View 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; }
}

View 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;
}

View 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; }
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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";
}

View 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; }
}

View 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; }
}

View 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;
}

View 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; }
}

View File

@ -0,0 +1,6 @@
namespace SPMS.Application.DTOs.Banner;
public class BannerListRequestDto
{
public string? Position { get; set; }
}

View 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; }
}

View 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; }
}

View 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;
}

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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();
}

View 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; }
}

View File

@ -0,0 +1,6 @@
namespace SPMS.Application.DTOs.Faq;
public class FaqListRequestDto
{
public string? Category { get; set; }
}

View 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; }
}

View 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;
}

View 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;
}

View 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();
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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