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
This commit is contained in:
김선규 2026-02-24 08:17:42 +00:00
commit 68fe6b91a5
16 changed files with 170 additions and 84 deletions

View File

@ -78,7 +78,7 @@ public class DeviceController : ControllerBase
[SwaggerOperation(Summary = "디바이스 목록", Description = "대시보드에서 디바이스 목록을 조회합니다. JWT 인증 필요.")] [SwaggerOperation(Summary = "디바이스 목록", Description = "대시보드에서 디바이스 목록을 조회합니다. JWT 인증 필요.")]
public async Task<IActionResult> GetListAsync([FromBody] DeviceListRequestDto request) public async Task<IActionResult> GetListAsync([FromBody] DeviceListRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _deviceService.GetListAsync(serviceId, request); var result = await _deviceService.GetListAsync(serviceId, request);
return Ok(ApiResponse<DeviceListResponseDto>.Success(result)); return Ok(ApiResponse<DeviceListResponseDto>.Success(result));
} }
@ -90,4 +90,11 @@ public class DeviceController : ControllerBase
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400); 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

@ -22,7 +22,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "일별 통계 조회", Description = "기간별 일별 발송/성공/실패/열람 통계를 조회합니다.")] [SwaggerOperation(Summary = "일별 통계 조회", Description = "기간별 일별 발송/성공/실패/열람 통계를 조회합니다.")]
public async Task<IActionResult> GetDailyAsync([FromBody] DailyStatRequestDto request) public async Task<IActionResult> GetDailyAsync([FromBody] DailyStatRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetDailyAsync(serviceId, request); var result = await _statsService.GetDailyAsync(serviceId, request);
return Ok(ApiResponse<DailyStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<DailyStatResponseDto>.Success(result, "조회 성공"));
} }
@ -31,7 +31,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "요약 통계 조회", Description = "대시보드 요약 통계를 조회합니다.")] [SwaggerOperation(Summary = "요약 통계 조회", Description = "대시보드 요약 통계를 조회합니다.")]
public async Task<IActionResult> GetSummaryAsync() public async Task<IActionResult> GetSummaryAsync()
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetSummaryAsync(serviceId); var result = await _statsService.GetSummaryAsync(serviceId);
return Ok(ApiResponse<SummaryStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<SummaryStatResponseDto>.Success(result, "조회 성공"));
} }
@ -40,7 +40,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "메시지별 통계 조회", Description = "특정 메시지의 발송 통계를 조회합니다.")] [SwaggerOperation(Summary = "메시지별 통계 조회", Description = "특정 메시지의 발송 통계를 조회합니다.")]
public async Task<IActionResult> GetMessageStatAsync([FromBody] MessageStatRequestDto request) public async Task<IActionResult> GetMessageStatAsync([FromBody] MessageStatRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetMessageStatAsync(serviceId, request); var result = await _statsService.GetMessageStatAsync(serviceId, request);
return Ok(ApiResponse<MessageStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<MessageStatResponseDto>.Success(result, "조회 성공"));
} }
@ -49,7 +49,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "시간대별 통계 조회", Description = "시간대별 발송 추이를 조회합니다.")] [SwaggerOperation(Summary = "시간대별 통계 조회", Description = "시간대별 발송 추이를 조회합니다.")]
public async Task<IActionResult> GetHourlyAsync([FromBody] HourlyStatRequestDto request) public async Task<IActionResult> GetHourlyAsync([FromBody] HourlyStatRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetHourlyAsync(serviceId, request); var result = await _statsService.GetHourlyAsync(serviceId, request);
return Ok(ApiResponse<HourlyStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<HourlyStatResponseDto>.Success(result, "조회 성공"));
} }
@ -58,7 +58,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "디바이스 통계 조회", Description = "플랫폼/모델별 디바이스 분포를 조회합니다.")] [SwaggerOperation(Summary = "디바이스 통계 조회", Description = "플랫폼/모델별 디바이스 분포를 조회합니다.")]
public async Task<IActionResult> GetDeviceStatAsync() public async Task<IActionResult> GetDeviceStatAsync()
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetDeviceStatAsync(serviceId); var result = await _statsService.GetDeviceStatAsync(serviceId);
return Ok(ApiResponse<DeviceStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<DeviceStatResponseDto>.Success(result, "조회 성공"));
} }
@ -67,7 +67,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "통계 리포트 다운로드", Description = "일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 파일로 다운로드합니다.")] [SwaggerOperation(Summary = "통계 리포트 다운로드", Description = "일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 파일로 다운로드합니다.")]
public async Task<IActionResult> ExportReportAsync([FromBody] StatsExportRequestDto request) public async Task<IActionResult> ExportReportAsync([FromBody] StatsExportRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var fileBytes = await _statsService.ExportReportAsync(serviceId, request); var fileBytes = await _statsService.ExportReportAsync(serviceId, request);
var fileName = $"stats_report_{request.StartDate}_{request.EndDate}.xlsx"; var fileName = $"stats_report_{request.StartDate}_{request.EndDate}.xlsx";
return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
@ -77,7 +77,7 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "실패원인 통계 조회", Description = "실패 원인별 집계를 상위 N개로 조회합니다.")] [SwaggerOperation(Summary = "실패원인 통계 조회", Description = "실패 원인별 집계를 상위 N개로 조회합니다.")]
public async Task<IActionResult> GetFailureStatAsync([FromBody] FailureStatRequestDto request) public async Task<IActionResult> GetFailureStatAsync([FromBody] FailureStatRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetFailureStatAsync(serviceId, request); var result = await _statsService.GetFailureStatAsync(serviceId, request);
return Ok(ApiResponse<FailureStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<FailureStatResponseDto>.Success(result, "조회 성공"));
} }
@ -86,16 +86,15 @@ public class StatsController : ControllerBase
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")]
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetOptionalServiceId();
var result = await _statsService.GetSendLogDetailAsync(serviceId, request); var result = await _statsService.GetSendLogDetailAsync(serviceId, request);
return Ok(ApiResponse<SendLogDetailResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<SendLogDetailResponseDto>.Success(result, "조회 성공"));
} }
private long GetServiceId() private long? GetOptionalServiceId()
{ {
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) if (HttpContext.Items.TryGetValue("ServiceId", out var obj) && obj is long id)
return serviceId; return id;
return null;
throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
} }
} }

View File

@ -15,12 +15,26 @@ public class SpmsHeaderOperationFilter : IOperationFilter
operation.Parameters ??= new List<OpenApiParameter>(); operation.Parameters ??= new List<OpenApiParameter>();
// v1/in/* 중 X-Service-Code가 필요한 경로만 (device, message, push, stats, file) // v1/in/* 중 X-Service-Code 대상 경로 판별
if (relativePath.StartsWith("v1/in/device") || var isStatsOrDeviceList = relativePath.StartsWith("v1/in/stats") ||
relativePath.StartsWith("v1/in/message") || relativePath == "v1/in/device/list";
var isRequired = relativePath.StartsWith("v1/in/message") ||
relativePath.StartsWith("v1/in/push") || relativePath.StartsWith("v1/in/push") ||
relativePath.StartsWith("v1/in/stats") || relativePath.StartsWith("v1/in/file");
relativePath.StartsWith("v1/in/file")) var isDeviceNonList = relativePath.StartsWith("v1/in/device") && !isStatsOrDeviceList;
if (isStatsOrDeviceList)
{
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 operation.Parameters.Add(new OpenApiParameter
{ {

View File

@ -12,20 +12,52 @@ public class ServiceCodeMiddleware
public async Task InvokeAsync(HttpContext context, IServiceRepository serviceRepository) public async Task InvokeAsync(HttpContext context, IServiceRepository serviceRepository)
{ {
if (context.Request.Path.StartsWithSegments("/v1/out") || var path = context.Request.Path;
context.Request.Path.StartsWithSegments("/v1/in/auth") ||
context.Request.Path.StartsWithSegments("/v1/in/account") || // === SKIP: X-Service-Code 불필요 ===
context.Request.Path.StartsWithSegments("/v1/in/public") || if (path.StartsWithSegments("/v1/out") ||
context.Request.Path.StartsWithSegments("/v1/in/service") || path.StartsWithSegments("/v1/in/auth") ||
(context.Request.Path.StartsWithSegments("/v1/in/device") && path.StartsWithSegments("/v1/in/account") ||
!context.Request.Path.StartsWithSegments("/v1/in/device/list")) || path.StartsWithSegments("/v1/in/public") ||
context.Request.Path.StartsWithSegments("/swagger") || path.StartsWithSegments("/v1/in/service") ||
context.Request.Path.StartsWithSegments("/health")) (path.StartsWithSegments("/v1/in/device") &&
!path.StartsWithSegments("/v1/in/device/list")) ||
path.StartsWithSegments("/swagger") ||
path.StartsWithSegments("/health"))
{ {
await _next(context); await _next(context);
return; return;
} }
// === OPTIONAL_FOR_ADMIN: 관리자는 X-Service-Code 선택 ===
if (path.StartsWithSegments("/v1/in/stats") ||
path.StartsWithSegments("/v1/in/device/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) || if (!context.Request.Headers.TryGetValue("X-Service-Code", out var serviceCode) ||
string.IsNullOrWhiteSpace(serviceCode)) string.IsNullOrWhiteSpace(serviceCode))
{ {
@ -36,7 +68,12 @@ public class ServiceCodeMiddleware
return; return;
} }
var service = await serviceRepository.GetByServiceCodeAsync(serviceCode!); await ValidateAndSetService(context, serviceRepository, serviceCode!);
}
private async Task ValidateAndSetService(HttpContext context, IServiceRepository serviceRepository, string serviceCode)
{
var service = await serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service == null) if (service == null)
{ {
context.Response.StatusCode = 404; context.Response.StatusCode = 404;

View File

@ -8,7 +8,7 @@ public interface IDeviceService
Task<DeviceInfoResponseDto> GetInfoAsync(long serviceId, DeviceInfoRequestDto request); Task<DeviceInfoResponseDto> GetInfoAsync(long serviceId, DeviceInfoRequestDto request);
Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request); Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request);
Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request); Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request);
Task<DeviceListResponseDto> GetListAsync(long serviceId, DeviceListRequestDto request); Task<DeviceListResponseDto> GetListAsync(long? serviceId, DeviceListRequestDto request);
Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request); Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request);
Task SetAgreeAsync(long serviceId, DeviceAgreeRequestDto request); Task SetAgreeAsync(long serviceId, DeviceAgreeRequestDto request);
} }

View File

@ -4,12 +4,12 @@ namespace SPMS.Application.Interfaces;
public interface IStatsService public interface IStatsService
{ {
Task<DailyStatResponseDto> GetDailyAsync(long serviceId, DailyStatRequestDto request); Task<DailyStatResponseDto> GetDailyAsync(long? serviceId, DailyStatRequestDto request);
Task<SummaryStatResponseDto> GetSummaryAsync(long serviceId); Task<SummaryStatResponseDto> GetSummaryAsync(long? serviceId);
Task<MessageStatResponseDto> GetMessageStatAsync(long serviceId, MessageStatRequestDto request); Task<MessageStatResponseDto> GetMessageStatAsync(long? serviceId, MessageStatRequestDto request);
Task<HourlyStatResponseDto> GetHourlyAsync(long serviceId, HourlyStatRequestDto request); Task<HourlyStatResponseDto> GetHourlyAsync(long? serviceId, HourlyStatRequestDto request);
Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId); Task<DeviceStatResponseDto> GetDeviceStatAsync(long? serviceId);
Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request); Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long? serviceId, SendLogDetailRequestDto request);
Task<byte[]> ExportReportAsync(long serviceId, StatsExportRequestDto request); Task<byte[]> ExportReportAsync(long? serviceId, StatsExportRequestDto request);
Task<FailureStatResponseDto> GetFailureStatAsync(long serviceId, FailureStatRequestDto request); Task<FailureStatResponseDto> GetFailureStatAsync(long? serviceId, FailureStatRequestDto request);
} }

View File

@ -119,7 +119,7 @@ public class DeviceService : IDeviceService
await _tokenCache.InvalidateAsync(serviceId, device.Id); await _tokenCache.InvalidateAsync(serviceId, device.Id);
} }
public async Task<DeviceListResponseDto> GetListAsync(long serviceId, DeviceListRequestDto request) public async Task<DeviceListResponseDto> GetListAsync(long? serviceId, DeviceListRequestDto request)
{ {
Platform? platform = null; Platform? platform = null;
if (!string.IsNullOrWhiteSpace(request.Platform)) if (!string.IsNullOrWhiteSpace(request.Platform))

View File

@ -29,7 +29,7 @@ public class StatsService : IStatsService
_messageRepository = messageRepository; _messageRepository = messageRepository;
} }
public async Task<DailyStatResponseDto> GetDailyAsync(long serviceId, DailyStatRequestDto request) public async Task<DailyStatResponseDto> GetDailyAsync(long? serviceId, DailyStatRequestDto request)
{ {
var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate);
@ -64,13 +64,21 @@ public class StatsService : IStatsService
}; };
} }
public async Task<SummaryStatResponseDto> GetSummaryAsync(long serviceId) public async Task<SummaryStatResponseDto> GetSummaryAsync(long? serviceId)
{ {
var totalDevices = await _deviceRepository.CountAsync(d => d.ServiceId == serviceId); var totalDevices = serviceId.HasValue
var activeDevices = await _deviceRepository.CountAsync(d => d.ServiceId == serviceId && d.IsActive); ? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value)
var totalMessages = await _messageRepository.CountAsync(m => m.ServiceId == serviceId && !m.IsDeleted); : await _deviceRepository.CountAsync(d => true);
var activeDevices = serviceId.HasValue
? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value && d.IsActive)
: await _deviceRepository.CountAsync(d => d.IsActive);
var totalMessages = serviceId.HasValue
? await _messageRepository.CountAsync(m => m.ServiceId == serviceId.Value && !m.IsDeleted)
: await _messageRepository.CountAsync(m => !m.IsDeleted);
var allStats = await _dailyStatRepository.FindAsync(s => s.ServiceId == serviceId); var allStats = serviceId.HasValue
? await _dailyStatRepository.FindAsync(s => s.ServiceId == serviceId.Value)
: await _dailyStatRepository.FindAsync(s => true);
var totalSend = allStats.Sum(s => (long)s.SentCnt); var totalSend = allStats.Sum(s => (long)s.SentCnt);
var totalSuccess = allStats.Sum(s => (long)s.SuccessCnt); var totalSuccess = allStats.Sum(s => (long)s.SuccessCnt);
var totalOpen = allStats.Sum(s => (long)s.OpenCnt); var totalOpen = allStats.Sum(s => (long)s.OpenCnt);
@ -107,20 +115,22 @@ public class StatsService : IStatsService
}; };
} }
public async Task<MessageStatResponseDto> GetMessageStatAsync(long serviceId, MessageStatRequestDto request) public async Task<MessageStatResponseDto> GetMessageStatAsync(long? serviceId, MessageStatRequestDto request)
{ {
if (string.IsNullOrWhiteSpace(request.MessageCode)) if (string.IsNullOrWhiteSpace(request.MessageCode))
throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400); throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400);
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); var message = serviceId.HasValue
? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value)
: await _messageRepository.GetByMessageCodeAsync(request.MessageCode);
if (message == null) if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
DateTime? startDate = ParseOptionalDate(request.StartDate); DateTime? startDate = ParseOptionalDate(request.StartDate);
DateTime? endDate = ParseOptionalDate(request.EndDate); DateTime? endDate = ParseOptionalDate(request.EndDate);
var stats = await _pushSendLogRepository.GetMessageStatsAsync(serviceId, message.Id, startDate, endDate); var stats = await _pushSendLogRepository.GetMessageStatsAsync(message.ServiceId, message.Id, startDate, endDate);
var dailyStats = await _pushSendLogRepository.GetMessageDailyStatsAsync(serviceId, message.Id, startDate, endDate); var dailyStats = await _pushSendLogRepository.GetMessageDailyStatsAsync(message.ServiceId, message.Id, startDate, endDate);
return new MessageStatResponseDto return new MessageStatResponseDto
{ {
@ -143,7 +153,7 @@ public class StatsService : IStatsService
}; };
} }
public async Task<HourlyStatResponseDto> GetHourlyAsync(long serviceId, HourlyStatRequestDto request) public async Task<HourlyStatResponseDto> GetHourlyAsync(long? serviceId, HourlyStatRequestDto request)
{ {
var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate);
@ -178,9 +188,11 @@ public class StatsService : IStatsService
}; };
} }
public async Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId) public async Task<DeviceStatResponseDto> GetDeviceStatAsync(long? serviceId)
{ {
var devices = await _deviceRepository.FindAsync(d => d.ServiceId == serviceId && d.IsActive); var devices = serviceId.HasValue
? await _deviceRepository.FindAsync(d => d.ServiceId == serviceId.Value && d.IsActive)
: await _deviceRepository.FindAsync(d => d.IsActive);
var total = devices.Count; var total = devices.Count;
var byPlatform = devices var byPlatform = devices
@ -236,9 +248,11 @@ public class StatsService : IStatsService
}; };
} }
public async Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request) public async Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long? serviceId, SendLogDetailRequestDto request)
{ {
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); var message = serviceId.HasValue
? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value)
: await _messageRepository.GetByMessageCodeAsync(request.MessageCode);
if (message == null) if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
@ -266,7 +280,7 @@ public class StatsService : IStatsService
} }
var (items, totalCount) = await _pushSendLogRepository.GetDetailLogPagedAsync( var (items, totalCount) = await _pushSendLogRepository.GetDetailLogPagedAsync(
serviceId, message.Id, request.Page, request.Size, status, platform); message.ServiceId, message.Id, request.Page, request.Size, status, platform);
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
@ -291,7 +305,7 @@ public class StatsService : IStatsService
}; };
} }
public async Task<byte[]> ExportReportAsync(long serviceId, StatsExportRequestDto request) public async Task<byte[]> ExportReportAsync(long? serviceId, StatsExportRequestDto request)
{ {
var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate);
@ -344,7 +358,9 @@ public class StatsService : IStatsService
ws2.Columns().AdjustToContents(); ws2.Columns().AdjustToContents();
// Sheet 3: 플랫폼별 통계 // Sheet 3: 플랫폼별 통계
var devices = await _deviceRepository.FindAsync(d => d.ServiceId == serviceId && d.IsActive); var devices = serviceId.HasValue
? await _deviceRepository.FindAsync(d => d.ServiceId == serviceId.Value && d.IsActive)
: await _deviceRepository.FindAsync(d => d.IsActive);
var total = devices.Count; var total = devices.Count;
var ws3 = workbook.Worksheets.Add("플랫폼별 통계"); var ws3 = workbook.Worksheets.Add("플랫폼별 통계");
@ -373,7 +389,7 @@ public class StatsService : IStatsService
return stream.ToArray(); return stream.ToArray();
} }
public async Task<FailureStatResponseDto> GetFailureStatAsync(long serviceId, FailureStatRequestDto request) public async Task<FailureStatResponseDto> GetFailureStatAsync(long? serviceId, FailureStatRequestDto request)
{ {
var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate);

View File

@ -31,6 +31,7 @@ public static class ErrorCodes
// === Service (3) === // === Service (3) ===
public const string DecryptionFailed = "131"; public const string DecryptionFailed = "131";
public const string InvalidCredentials = "132"; public const string InvalidCredentials = "132";
public const string ServiceScopeRequired = "133";
// === Device (4) === // === Device (4) ===
public const string DeviceNotFound = "141"; public const string DeviceNotFound = "141";

View File

@ -28,6 +28,9 @@ public class SpmsException : Exception
public static SpmsException Conflict(string message) public static SpmsException Conflict(string message)
=> new(ErrorCodes.Conflict, message, 409); => new(ErrorCodes.Conflict, message, 409);
public static SpmsException Forbidden(string message)
=> new(ErrorCodes.Forbidden, message, 403);
public static SpmsException LimitExceeded(string message) public static SpmsException LimitExceeded(string message)
=> new(ErrorCodes.LimitExceeded, message, 429); => new(ErrorCodes.LimitExceeded, message, 429);
} }

View File

@ -4,7 +4,7 @@ namespace SPMS.Domain.Interfaces;
public interface IDailyStatRepository : IRepository<DailyStat> public interface IDailyStatRepository : IRepository<DailyStat>
{ {
Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate); Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long? serviceId, DateOnly startDate, DateOnly endDate);
Task<DailyStat?> GetByDateAsync(long serviceId, DateOnly date); Task<DailyStat?> GetByDateAsync(long? serviceId, DateOnly date);
Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt); Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt);
} }

View File

@ -10,7 +10,7 @@ public interface IDeviceRepository : IRepository<Device>
Task<int> GetActiveCountByServiceAsync(long serviceId); Task<int> GetActiveCountByServiceAsync(long serviceId);
Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform); Task<IReadOnlyList<Device>> GetByPlatformAsync(long serviceId, Platform platform);
Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync( Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(
long serviceId, int page, int size, long? serviceId, int page, int size,
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null); bool? isActive = null, List<int>? tags = null);
} }

View File

@ -11,7 +11,7 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
PushResult? status = null, PushResult? status = null,
DateTime? startDate = null, DateTime? endDate = null); DateTime? startDate = null, DateTime? endDate = null);
Task<List<HourlyStatRaw>> GetHourlyStatsAsync(long serviceId, DateTime startDate, DateTime endDate); Task<List<HourlyStatRaw>> GetHourlyStatsAsync(long? serviceId, DateTime startDate, DateTime endDate);
Task<MessageStatRaw?> GetMessageStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate); Task<MessageStatRaw?> GetMessageStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate);
Task<List<MessageDailyStatRaw>> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate); Task<List<MessageDailyStatRaw>> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate);
Task<(IReadOnlyList<PushSendLog> Items, int TotalCount)> GetDetailLogPagedAsync( Task<(IReadOnlyList<PushSendLog> Items, int TotalCount)> GetDetailLogPagedAsync(
@ -21,7 +21,7 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
long serviceId, DateTime startDate, DateTime endDate, long serviceId, DateTime startDate, DateTime endDate,
long? messageId = null, long? deviceId = null, long? messageId = null, long? deviceId = null,
PushResult? status = null, int maxCount = 100000); PushResult? status = null, int maxCount = 100000);
Task<List<FailureStatRaw>> GetFailureStatsAsync(long serviceId, DateTime startDate, DateTime endDate, int limit); Task<List<FailureStatRaw>> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit);
} }
public class HourlyStatRaw public class HourlyStatRaw

View File

@ -8,18 +8,20 @@ public class DailyStatRepository : Repository<DailyStat>, IDailyStatRepository
{ {
public DailyStatRepository(AppDbContext context) : base(context) { } public DailyStatRepository(AppDbContext context) : base(context) { }
public async Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate) public async Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long? serviceId, DateOnly startDate, DateOnly endDate)
{ {
return await _dbSet var query = _dbSet.Where(s => s.StatDate >= startDate && s.StatDate <= endDate);
.Where(s => s.ServiceId == serviceId && s.StatDate >= startDate && s.StatDate <= endDate) if (serviceId.HasValue)
.OrderByDescending(s => s.StatDate) query = query.Where(s => s.ServiceId == serviceId.Value);
.ToListAsync(); return await query.OrderByDescending(s => s.StatDate).ToListAsync();
} }
public async Task<DailyStat?> GetByDateAsync(long serviceId, DateOnly date) public async Task<DailyStat?> GetByDateAsync(long? serviceId, DateOnly date)
{ {
return await _dbSet var query = _dbSet.Where(s => s.StatDate == date);
.FirstOrDefaultAsync(s => s.ServiceId == serviceId && s.StatDate == date); if (serviceId.HasValue)
query = query.Where(s => s.ServiceId == serviceId.Value);
return await query.FirstOrDefaultAsync();
} }
public async Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt) public async Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt)

View File

@ -32,11 +32,13 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
} }
public async Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync( public async Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(
long serviceId, int page, int size, long? serviceId, int page, int size,
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null) bool? isActive = null, List<int>? tags = null)
{ {
var query = _dbSet.Where(d => d.ServiceId == serviceId); IQueryable<Device> query = _dbSet;
if (serviceId.HasValue)
query = query.Where(d => d.ServiceId == serviceId.Value);
if (platform.HasValue) if (platform.HasValue)
query = query.Where(d => d.Platform == platform.Value); query = query.Where(d => d.Platform == platform.Value);

View File

@ -45,10 +45,13 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
return (items, totalCount); return (items, totalCount);
} }
public async Task<List<HourlyStatRaw>> GetHourlyStatsAsync(long serviceId, DateTime startDate, DateTime endDate) public async Task<List<HourlyStatRaw>> GetHourlyStatsAsync(long? serviceId, DateTime startDate, DateTime endDate)
{ {
return await _dbSet var query = _dbSet.Where(l => l.SentAt >= startDate && l.SentAt < endDate);
.Where(l => l.ServiceId == serviceId && l.SentAt >= startDate && l.SentAt < endDate) if (serviceId.HasValue)
query = query.Where(l => l.ServiceId == serviceId.Value);
return await query
.GroupBy(l => l.SentAt.Hour) .GroupBy(l => l.SentAt.Hour)
.Select(g => new HourlyStatRaw .Select(g => new HourlyStatRaw
{ {
@ -157,14 +160,16 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
.ToListAsync(); .ToListAsync();
} }
public async Task<List<FailureStatRaw>> GetFailureStatsAsync(long serviceId, DateTime startDate, DateTime endDate, int limit) public async Task<List<FailureStatRaw>> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit)
{ {
return await _dbSet var query = _dbSet.Where(l => l.Status == PushResult.Failed
.Where(l => l.ServiceId == serviceId
&& l.Status == PushResult.Failed
&& l.SentAt >= startDate && l.SentAt >= startDate
&& l.SentAt < endDate && l.SentAt < endDate
&& l.FailReason != null) && l.FailReason != null);
if (serviceId.HasValue)
query = query.Where(l => l.ServiceId == serviceId.Value);
return await query
.GroupBy(l => l.FailReason!) .GroupBy(l => l.FailReason!)
.Select(g => new FailureStatRaw .Select(g => new FailureStatRaw
{ {