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
This commit is contained in:
parent
a37e57f789
commit
f04eb44fff
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
relativePath.StartsWith("v1/in/push") ||
|
var isRequired = relativePath.StartsWith("v1/in/message") ||
|
||||||
relativePath.StartsWith("v1/in/stats") ||
|
relativePath.StartsWith("v1/in/push") ||
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.SentAt >= startDate
|
||||||
&& l.Status == PushResult.Failed
|
&& l.SentAt < endDate
|
||||||
&& l.SentAt >= startDate
|
&& l.FailReason != null);
|
||||||
&& l.SentAt < endDate
|
if (serviceId.HasValue)
|
||||||
&& l.FailReason != null)
|
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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user