| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431 |
- 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<GlobalExceptionHandler>();
- 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<string> 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<string, string>
- {
- ["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<JsonElement>();
- _token = json.GetProperty("access_token").GetString()!;
- _expiresAt = DateTime.UtcNow.AddSeconds(json.GetProperty("expires_in").GetInt32());
- return _token;
- }
- public async Task<HttpResponseMessage> 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;
- }
- }
|