using Web.Api.Common; using Microsoft.OpenApi; using SharedKernel; namespace Web.Api; public static class DependencyInjection { public static IServiceCollection AddPresentation(this IServiceCollection services, AppSettings settings) { services.AddExceptionHandler(); services.AddProblemDetails(); services.AddEndpointsApiExplorer(); services.AddSwaggerGen(options => { // Nested type (예: GoogleLogin.Request, ForgotPassword.Request, Developers.Apps.Create.Request 등) // 이 short name `Request` 로 schemaId 충돌 → FullName 으로 unique 보장 (`+` 를 `.` 로 치환해 가독성). options.CustomSchemaIds(t => t.FullName?.Replace('+', '.') ?? t.Name); // internal: 기존 /api/* (대시보드 전용) options.SwaggerDoc("internal", new OpenApiInfo { Title = "DPOT Internal API", Version = "v1", Description = "사용자/관리자 페이지 전용. 외부 노출 안 됨." }); // public: /v1/* + /oauth/token (외부 개발자 포털용) options.SwaggerDoc("public", new OpenApiInfo { Title = "DPOT Public API", Version = "v1.1.0", Description = """ DPOT 크리에이터 후원·도네이션 플랫폼의 외부 개발자용 공개 API 입니다. ## 1. 사전 준비사항 공개 API 호출을 위해서는 아래 절차를 **순서대로** 완료해야 합니다. ### 1-1. 개발자 계정 등록 1. https://developers.dpot.live 에서 일반 DPOT 계정으로 로그인 2. **온보딩 페이지에서 사업자/개인 정보 입력** 3. **NICE 본인인증(KYC)** 으로 실명/CI 검증 4. 관리자 승인 대기 (영업일 기준 1~3 일) - 승인 상태는 `GET /api/developers/profile` 또는 포털 헤더에서 확인 가능 ### 1-2. 자격 증명 발급 승인 후 두 가지 방식 중 선택해 토큰을 발급 받습니다. | 방식 | 발급 위치 | 인증 헤더 | 권장 용도 | |---|---|---|---| | **OAuth2 Client Credentials** | `developers.dpot.live/apps` 에서 앱 생성 | `Authorization: Bearer {access_token}` | 서버 ↔ 서버, 토큰 만료/갱신 | | **Personal Access Token (PAT)** | `developers.dpot.live/tokens` | `Authorization: Bearer dpot_pat_xxx` | 개인 스크립트, 단기 테스트 | OAuth2 access token 발급 예시: ```bash curl -X POST https://api.dpot.live/oauth/token \ -d "grant_type=client_credentials" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "scope=read:members read:products" ``` ### 1-3. Scope 요청 각 API 는 **flat scope** 단위로 권한을 검사합니다. 토큰 발급 시 필요한 scope 를 명시하세요. scope 가 부족하면 `403 Forbidden` 으로 응답합니다. | Scope | 설명 | |---|---| | `read:members` | 회원(일반/크리에이터) 목록 조회 | | `read:products` | 상품 목록 조회 | | `read:coupons` | 쿠폰 코드 목록·상세 조회 | | `read:stats` | 상품 판매 통계 조회 | | `read:channels` | 채널 후원 코드 확인 | | `read:purchases` | 결제 보고 조회 (단건/목록 대사) | | `write:purchases` | 결제 등록/취소 보고 — **OAuth2 앱 토큰 전용** | ### 1-4. Rate Limit 기본 한도: **분당 100 회**, **일일 10,000 회** (앱/PAT 별 독립 카운터). 초과 시 `429 Too Many Requests` + `Retry-After` 헤더가 반환됩니다. --- ## 2. 제공 API v1 에서 제공하는 엔드포인트는 다음과 같습니다. 각 항목 클릭 시 상세 스펙으로 이동합니다. | # | 메서드 | 경로 | 요약 | 필요 scope | |---|---|---|---|---| | 1 | GET | `/v1/members?hasChannel=` | 회원 조회 (`hasChannel` 으로 일반/크리에이터/전체 필터) | `read:members` | | 2 | GET | `/v1/products` | 상품 목록 (쿠폰 정보 포함) | `read:products` | | 3 | GET | `/v1/games/{gameID}/coupons` | 게임 쿠폰 코드 대량 조회 | `read:coupons` | | 4 | GET | `/v1/coupons/codes/{code}` | 쿠폰 코드 단일 상세 | `read:coupons` | | 5 | GET | `/v1/stats/products` | 게임사별 상품 판매 통계 | `read:stats` | | 6 | GET | `/v1/channels/{code}` | 채널 후원 코드 확인 (결제 보고 사전 검증) | `read:channels` | | 7 | POST | `/v1/purchases` | 결제 등록 — 채널 수수료 보류 적립 (+14일 확정) | `write:purchases` | | 8 | POST | `/v1/purchases/{orderID}/cancel` | 결제 취소 — 보류 취소 또는 확정 수수료 회수 | `write:purchases` | | 9 | GET | `/v1/purchases/{orderID}` | 결제 단건 조회 | `read:purchases` | | 10 | GET | `/v1/purchases?status=&from=&to=` | 결제 목록 조회 (대사) | `read:purchases` | ### 회원 정보 비공개 정책 회원 응답에서 다음 정보는 **노출되지 않습니다**: - 이메일 평문 (대신 `a***@example.com` 형식 마스킹) - 휴대전화번호 / CI / DI / 실명 - 결제 수단 정보 --- ## 3. 게임사 결제 보고 연동 가이드 게임 내 결제(인앱 결제)가 발생하면 DPOT 에 보고하여, 유저가 입력한 **채널 후원 코드**의 크리에이터에게 판매 수수료가 적립되도록 하는 연동입니다. 전 구간 **게임사 서버 ↔ DPOT 서버** 통신이며, 게임 클라이언트에서 직접 호출하지 않습니다 (`client_secret` 이 클라이언트에 노출되면 안 됩니다). ### 3-1. 전체 흐름 ```text [게임 유저] [게임사 서버] [DPOT] │ ① 후원코드 입력 │ │ ├──────────────────────▶│ ② GET /v1/channels/{code} │ │ ├───────────────────────────────▶│ 코드 존재/활성 확인 │ ◀── 확인 결과 ────────┤◀───────────────────────────────┤ │ │ │ │ ③ 인앱 결제 완료 │ │ ├──────────────────────▶│ ④ POST /v1/purchases │ │ ├───────────────────────────────▶│ 보류(Pending) 적립 생성 │ │ │ ⑤ +14일 후 자동 확정 │ │ │ → 채널에 수수료 입금 │ ⑥ 스토어 환불 발생 │ │ ├──────────────────────▶│ ⑦ POST /v1/purchases/{orderID}/cancel │ ├───────────────────────────────▶│ 보류 취소 / 확정분 회수 ``` ### 3-2. 사전 조건 1. DPOT 운영팀과 제휴 계약 → **게임 등록** (게임 코드 발급 + 수수료율 설정은 DPOT 측에서 수행) 2. 개발자 계정 승인 (섹션 1-1) 후 앱 생성 — scope 는 `write:purchases read:purchases read:channels` 3. **결제 등록/취소는 OAuth2 앱 토큰 전용**입니다. PAT 으로는 조회(`read:purchases`)만 가능합니다. ### 3-3. Step 1 — 후원 코드 입력과 검증 게임 설정 화면 등에 "크리에이터 후원 코드" 입력란을 제공하세요 (4~7자 영문+숫자, 대소문자 무관). 입력 시점에 `GET /v1/channels/{code}` 로 검증해 유저에게 즉시 피드백합니다. | 응답 | 처리 | |---|---| | `200` + `active: true` | 코드 저장, 이후 결제 보고에 사용 | | `200` + `active: false` | "사용할 수 없는 코드" 안내 — 결제 보고가 거부되므로 저장하지 않음 | | `404` | "존재하지 않는 코드" 안내 | ### 3-4. Step 2 — 결제 보고 (`POST /v1/purchases`) 유저의 인앱 결제가 **스토어에서 확정된 후** 서버에서 보고합니다. | 필드 | 규칙 | |---|---| | `orderID` | **마켓 거래 ID 원문 그대로** — Google `GPA.xxxx-xxxx-xxxx-xxxxx`, Apple Transaction ID 등. 자체 채번/가공 금지 (분쟁 시 영수증 대조 근거) | | `marketplace` | 1=구글, 2=애플, 3=MS, 4=갤럭시, 5=원스토어, 6=기타 | | `gameCode` | DPOT 이 발급한 게임 코드 | | `orderPrice` | 유저 실결제 금액 (KRW) | | `productID` | 인앱 상품 SKU — **전달 권장** (DPOT 측 금액 검증에 사용) | | `channelCode` | 유저가 입력한 후원 코드 | ```bash curl -X POST https://api.dpot.live/v1/purchases \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{ "channelCode": "ABC123", "orderID": "GPA.3312-8767-0710-71943", "marketplace": 1, "gameCode": "XXXXXX", "orderPrice": 11000, "productID": "diamond_1000" }' ``` `201` 응답의 `status` 는 항상 `Pending` 이며, 수수료는 `confirmDueAt`(등록 +14일, 주말·공휴일 포함) 에 자동 확정되어 채널에 입금됩니다. 응답의 `commissionAmount` 는 채널 적립 예정액입니다. **멱등성과 재시도** — 동일 (`marketplace`, `orderID`) 조합은 1회만 등록됩니다. | 상황 | 대응 | |---|---| | 타임아웃 / 네트워크 오류 / `5xx` | **동일 페이로드로 재시도해도 안전** (이미 등록됐다면 `409`) | | `409 Purchase.Duplicate` | 이미 등록된 주문 — **성공으로 간주**하고 종료 | | `400` / `403` / `404` | 재시도 금지 — 페이로드·scope·게임 코드 점검 | ### 3-5. Step 3 — 환불 시 취소 보고 (`POST /v1/purchases/{orderID}/cancel`) 스토어 환불을 확인하면 **반드시** 취소를 보고하세요. 미보고 시 환불된 결제의 수수료가 크리에이터에게 확정 지급되며, 정산 대사에서 불일치로 기록됩니다. ```bash curl -X POST "https://api.dpot.live/v1/purchases/GPA.3312-8767-0710-71943/cancel" \ -H "Authorization: Bearer {access_token}" ``` | 케이스 | 응답 | |---|---| | 보류 중(14일 내) 취소 | 적립 자체가 소멸 — `recoveredAmount: 0`, `shortfallAmount: 0` | | 확정 후 취소 | 채널 적립분 회수 — `recoveredAmount` 에 회수액, 잔액 부족분은 `shortfallAmount` | | `409 Purchase.AlreadyCanceled` | 이미 처리됨 — 성공으로 간주 | 취소는 **등록한 앱만** 가능합니다. 동일 `orderID` 가 여러 마켓에 있으면 `?marketplace=` 를 지정하세요. ### 3-6. Step 4 — 정산 대사 `GET /v1/purchases?from=&to=&status=&page=&size=` 로 기간별 등록 내역을 받아 자체 결제 DB 와 주기적으로 대조하세요 (일 1회 권장). 누락 건은 추가 보고하고, 금액 불일치는 DPOT 운영팀과 협의합니다. 계약에 따라 월 정산 시 스토어 매출 리포트 제출이 요구될 수 있습니다. ### 3-7. 토큰 관리 구현 예시 `access_token` 은 **1시간(3600초)** 유효합니다. 매 호출마다 발급하지 말고 캐시 후 만료 60초 전에 재발급하세요. (토큰 발급 호출도 rate limit 에 포함됩니다.) C# (.NET): ```csharp public sealed class DpotApiClient(HttpClient http, string clientID, string clientSecret) { private string? _token; private DateTime _expiresAt; private async Task GetTokenAsync() { if (_token is not null && DateTime.UtcNow < _expiresAt.AddSeconds(-60)) { return _token; } var res = await http.PostAsync("https://api.dpot.live/oauth/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = clientID, ["client_secret"] = clientSecret, ["scope"] = "write:purchases read:purchases read:channels" })); res.EnsureSuccessStatusCode(); var json = await res.Content.ReadFromJsonAsync(); _token = json.GetProperty("access_token").GetString()!; _expiresAt = DateTime.UtcNow.AddSeconds(json.GetProperty("expires_in").GetInt32()); return _token; } public async Task ReportPurchaseAsync(object payload) { var req = new HttpRequestMessage(HttpMethod.Post, "https://api.dpot.live/v1/purchases"); req.Headers.Authorization = new("Bearer", await GetTokenAsync()); req.Content = JsonContent.Create(payload); return await http.SendAsync(req); } } ``` Node.js: ```javascript let cached = { token: null, expiresAt: 0 }; async function getToken() { if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token; const res = await fetch('https://api.dpot.live/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.DPOT_CLIENT_ID, client_secret: process.env.DPOT_CLIENT_SECRET, scope: 'write:purchases read:purchases read:channels' }) }); const json = await res.json(); cached = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }; return cached.token; } async function reportPurchase(payload) { const res = await fetch('https://api.dpot.live/v1/purchases', { method: 'POST', headers: { 'Authorization': `Bearer ${await getToken()}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.status === 409) return { duplicated: true }; // 이미 등록 — 성공 간주 if (!res.ok) throw new Error(`DPOT report failed: ${res.status}`); return await res.json(); } ``` ### 3-8. 상태 / 오류 빠른 참조 결제 보고의 `status`: | status | 의미 | |---|---| | `Pending` | 보류 — `confirmDueAt` 에 자동 확정 예정 | | `Confirmed` | 확정 — 채널 수수료 입금 완료 | | `Canceled` | 취소됨 | 결제 보고 관련 `errors[].code`: | Code | HTTP | 의미 / 대응 | |---|---|---| | `Channel.NotFound` | 404 | 후원 코드 없음 — 유저 입력 재확인 | | `Channel.Inactive` | 400 | 비활성/탈퇴 채널 — 저장된 코드 해제 안내 | | `Game.NotFound` | 404 | 게임 코드 오류 — 발급받은 코드 확인 | | `Game.Inactive` | 400 | 게임 비활성 — DPOT 운영팀 문의 | | `Game.ApiCommissionNotConfigured` | 400 | 수수료율 미설정 — DPOT 운영팀 문의 | | `Purchase.Duplicate` | 409 | 이미 등록된 주문 — 성공 간주 | | `Purchase.NotFound` | 404 | 취소/조회 대상 없음 (타 앱이 등록한 건 포함) | | `Purchase.AlreadyCanceled` | 409 | 이미 취소됨 — 성공 간주 | | `Purchase.AmbiguousOrderID` | 400 | 여러 마켓에 동일 orderID — `marketplace` 지정 필요 | | `Purchase.AppNotActive` | 403 | 앱 정지 상태 — DPOT 운영팀 문의 | --- ## 4. API 변경 이력 ### v1.1.0 — 2026-06-11 **결제 보고 (게임사 파트너)** - `POST /v1/purchases` 추가 — 게임 내 결제 등록, 채널 후원 코드 기반 판매 수수료 보류 적립 (등록 +14일 후 확정) - `POST /v1/purchases/{orderID}/cancel` 추가 — 결제 취소 (등록한 앱만 가능) - `GET /v1/purchases/{orderID}` / `GET /v1/purchases` 추가 — 파트너 대사용 조회 - `GET /v1/channels/{code}` 추가 — 채널 후원 코드 사전 검증 - 신규 scope: `write:purchases`(OAuth2 앱 토큰 전용), `read:purchases`, `read:channels` ### v1.0.0 — 2026-06-05 **Initial Release** - `GET /v1/members?hasChannel=` 추가 — `hasChannel` 쿼리로 일반/크리에이터/전체 필터 - `GET /v1/products` 추가 (쿠폰 상품의 경우 `coupon` 필드 포함) - `GET /v1/games/{gameID}/coupons` 추가 - `GET /v1/coupons/codes/{code}` 추가 - `GET /v1/stats/products` 추가 --- ## 5. 응답 / 오류 코드 ### 정상 응답 모든 정상 응답은 다음 envelope 으로 감쌉니다. ```json { "success": true, "data": { ... } } ``` ### 오류 응답 오류는 RFC 7807 ProblemDetails 형식으로 반환됩니다. ```json { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", "title": "Member not found", "status": 404, "detail": "ID 12345 에 해당하는 회원이 없습니다.", "errors": [ { "code": "Member.NotFound", "description": "..." } ] } ``` ### HTTP Status Code | Status | 의미 | 처리 가이드 | |---|---|---| | `200` | 성공 | — | | `400` | 요청 파라미터 검증 실패 | `errors[].description` 확인 후 재호출 | | `401` | 인증 토큰 없음 / 만료 | 토큰 재발급 | | `403` | scope 부족 또는 권한 거부 | 앱의 scope 설정 확인 | | `404` | 대상 리소스 없음 | 식별자 재확인 | | `409` | 충돌 (중복/상태 불일치) | 현재 상태 재조회 후 재시도 | | `429` | Rate limit 초과 | `Retry-After` 초 만큼 대기 | | `500` | 서버 내부 오류 | DPOT 운영팀에 문의 | ### 비즈니스 오류 코드 `errors[].code` 필드로 세분화된 코드가 제공됩니다. | Code | HTTP | 의미 | |---|---|---| | `Auth.MissingToken` | 401 | Authorization 헤더 누락 | | `Auth.InvalidToken` | 401 | 토큰 형식 오류 / 만료 / 위조 | | `Auth.ScopeRequired` | 403 | 토큰 scope 부족 | | `Member.NotFound` | 404 | 회원 없음 | | `Game.NotFound` | 404 | 게임 없음 | | `Product.NotFound` | 404 | 상품 없음 | | `Coupon.NotFound` | 404 | 쿠폰 코드 없음 | | `Coupon.InvalidCode` | 400 | 코드 형식 오류 (길이/문자) | | `Channel.NotFound` | 404 | 채널 후원 코드 없음 | | `Channel.Inactive` | 400 | 비활성/탈퇴 채널 | | `Game.Inactive` | 400 | 비활성 게임 | | `Game.ApiCommissionNotConfigured` | 400 | 결제 보고 수수료율 미설정 게임 | | `Purchase.Duplicate` | 409 | 결제 보고 중복 (marketplace + orderID) | | `Purchase.NotFound` | 404 | 결제 보고 없음 | | `Purchase.AlreadyCanceled` | 409 | 이미 취소된 결제 보고 | | `Purchase.AmbiguousOrderID` | 400 | 여러 마켓에 동일 orderID — marketplace 지정 필요 | | `Purchase.AppNotActive` | 403 | 앱 비활성 상태 | | `RateLimit.Exceeded` | 429 | 호출량 초과 | """ }); options.DocInclusionPredicate((docName, apiDesc) => { var groupName = apiDesc.GroupName ?? "internal"; return docName == groupName; }); options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", Description = "JWT / OAuth2 access_token / Personal Access Token (dpot_pat_xxx) 을 입력하세요." }); options.AddSecurityRequirement(document => new() { [new OpenApiSecuritySchemeReference("Bearer", document)] = [] }); // OpenAPI 3.x servers field — Scalar / Swagger UI 의 server selector + "Try it out" base URL. // 환경별 (PROD=api.dpot.live / DEV=dev-api.dpot.live / LOCAL=localhost:4000) 로 자동 채워짐. options.AddServer(new OpenApiServer { Url = settings.App.ApiURL, Description = $"{settings.App.Name} ({settings.App.ApiURL})" }); }); return services; } }