DependencyInjection.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. using Web.Api.Common;
  2. using Microsoft.OpenApi;
  3. using SharedKernel;
  4. namespace Web.Api;
  5. public static class DependencyInjection
  6. {
  7. public static IServiceCollection AddPresentation(this IServiceCollection services, AppSettings settings)
  8. {
  9. services.AddExceptionHandler<GlobalExceptionHandler>();
  10. services.AddProblemDetails();
  11. services.AddEndpointsApiExplorer();
  12. services.AddSwaggerGen(options =>
  13. {
  14. // Nested type (예: GoogleLogin.Request, ForgotPassword.Request, Developers.Apps.Create.Request 등)
  15. // 이 short name `Request` 로 schemaId 충돌 → FullName 으로 unique 보장 (`+` 를 `.` 로 치환해 가독성).
  16. options.CustomSchemaIds(t => t.FullName?.Replace('+', '.') ?? t.Name);
  17. // internal: 기존 /api/* (대시보드 전용)
  18. options.SwaggerDoc("internal", new OpenApiInfo
  19. {
  20. Title = "DPOT Internal API",
  21. Version = "v1",
  22. Description = "사용자/관리자 페이지 전용. 외부 노출 안 됨."
  23. });
  24. // public: /v1/* + /oauth/token (외부 개발자 포털용)
  25. options.SwaggerDoc("public", new OpenApiInfo
  26. {
  27. Title = "DPOT Public API",
  28. Version = "v1.1.0",
  29. Description = """
  30. DPOT 크리에이터 후원·도네이션 플랫폼의 외부 개발자용 공개 API 입니다.
  31. ## 1. 사전 준비사항
  32. 공개 API 호출을 위해서는 아래 절차를 **순서대로** 완료해야 합니다.
  33. ### 1-1. 개발자 계정 등록
  34. 1. https://developers.dpot.live 에서 일반 DPOT 계정으로 로그인
  35. 2. **온보딩 페이지에서 사업자/개인 정보 입력**
  36. 3. **NICE 본인인증(KYC)** 으로 실명/CI 검증
  37. 4. 관리자 승인 대기 (영업일 기준 1~3 일)
  38. - 승인 상태는 `GET /api/developers/profile` 또는 포털 헤더에서 확인 가능
  39. ### 1-2. 자격 증명 발급
  40. 승인 후 두 가지 방식 중 선택해 토큰을 발급 받습니다.
  41. | 방식 | 발급 위치 | 인증 헤더 | 권장 용도 |
  42. |---|---|---|---|
  43. | **OAuth2 Client Credentials** | `developers.dpot.live/apps` 에서 앱 생성 | `Authorization: Bearer {access_token}` | 서버 ↔ 서버, 토큰 만료/갱신 |
  44. | **Personal Access Token (PAT)** | `developers.dpot.live/tokens` | `Authorization: Bearer dpot_pat_xxx` | 개인 스크립트, 단기 테스트 |
  45. OAuth2 access token 발급 예시:
  46. ```bash
  47. curl -X POST https://api.dpot.live/oauth/token \
  48. -d "grant_type=client_credentials" \
  49. -d "client_id=YOUR_CLIENT_ID" \
  50. -d "client_secret=YOUR_CLIENT_SECRET" \
  51. -d "scope=read:members read:products"
  52. ```
  53. ### 1-3. Scope 요청
  54. 각 API 는 **flat scope** 단위로 권한을 검사합니다. 토큰 발급 시 필요한 scope 를 명시하세요.
  55. scope 가 부족하면 `403 Forbidden` 으로 응답합니다.
  56. | Scope | 설명 |
  57. |---|---|
  58. | `read:members` | 회원(일반/크리에이터) 목록 조회 |
  59. | `read:products` | 상품 목록 조회 |
  60. | `read:coupons` | 쿠폰 코드 목록·상세 조회 |
  61. | `read:stats` | 상품 판매 통계 조회 |
  62. | `read:channels` | 채널 후원 코드 확인 |
  63. | `read:purchases` | 결제 보고 조회 (단건/목록 대사) |
  64. | `write:purchases` | 결제 등록/취소 보고 — **OAuth2 앱 토큰 전용** |
  65. ### 1-4. Rate Limit
  66. 기본 한도: **분당 100 회**, **일일 10,000 회** (앱/PAT 별 독립 카운터).
  67. 초과 시 `429 Too Many Requests` + `Retry-After` 헤더가 반환됩니다.
  68. ---
  69. ## 2. 제공 API
  70. v1 에서 제공하는 엔드포인트는 다음과 같습니다. 각 항목 클릭 시 상세 스펙으로 이동합니다.
  71. | # | 메서드 | 경로 | 요약 | 필요 scope |
  72. |---|---|---|---|---|
  73. | 1 | GET | `/v1/members?hasChannel=` | 회원 조회 (`hasChannel` 으로 일반/크리에이터/전체 필터) | `read:members` |
  74. | 2 | GET | `/v1/products` | 상품 목록 (쿠폰 정보 포함) | `read:products` |
  75. | 3 | GET | `/v1/games/{gameID}/coupons` | 게임 쿠폰 코드 대량 조회 | `read:coupons` |
  76. | 4 | GET | `/v1/coupons/codes/{code}` | 쿠폰 코드 단일 상세 | `read:coupons` |
  77. | 5 | GET | `/v1/stats/products` | 게임사별 상품 판매 통계 | `read:stats` |
  78. | 6 | GET | `/v1/channels/{code}` | 채널 후원 코드 확인 (결제 보고 사전 검증) | `read:channels` |
  79. | 7 | POST | `/v1/purchases` | 결제 등록 — 채널 수수료 보류 적립 (+14일 확정) | `write:purchases` |
  80. | 8 | POST | `/v1/purchases/{orderID}/cancel` | 결제 취소 — 보류 취소 또는 확정 수수료 회수 | `write:purchases` |
  81. | 9 | GET | `/v1/purchases/{orderID}` | 결제 단건 조회 | `read:purchases` |
  82. | 10 | GET | `/v1/purchases?status=&from=&to=` | 결제 목록 조회 (대사) | `read:purchases` |
  83. ### 회원 정보 비공개 정책
  84. 회원 응답에서 다음 정보는 **노출되지 않습니다**:
  85. - 이메일 평문 (대신 `a***@example.com` 형식 마스킹)
  86. - 휴대전화번호 / CI / DI / 실명
  87. - 결제 수단 정보
  88. ---
  89. ## 3. 게임사 결제 보고 연동 가이드
  90. 게임 내 결제(인앱 결제)가 발생하면 DPOT 에 보고하여, 유저가 입력한 **채널 후원 코드**의
  91. 크리에이터에게 판매 수수료가 적립되도록 하는 연동입니다.
  92. 전 구간 **게임사 서버 ↔ DPOT 서버** 통신이며, 게임 클라이언트에서 직접 호출하지 않습니다
  93. (`client_secret` 이 클라이언트에 노출되면 안 됩니다).
  94. ### 3-1. 전체 흐름
  95. ```text
  96. [게임 유저] [게임사 서버] [DPOT]
  97. │ ① 후원코드 입력 │ │
  98. ├──────────────────────▶│ ② GET /v1/channels/{code} │
  99. │ ├───────────────────────────────▶│ 코드 존재/활성 확인
  100. │ ◀── 확인 결과 ────────┤◀───────────────────────────────┤
  101. │ │ │
  102. │ ③ 인앱 결제 완료 │ │
  103. ├──────────────────────▶│ ④ POST /v1/purchases │
  104. │ ├───────────────────────────────▶│ 보류(Pending) 적립 생성
  105. │ │ │ ⑤ +14일 후 자동 확정
  106. │ │ │ → 채널에 수수료 입금
  107. │ ⑥ 스토어 환불 발생 │ │
  108. ├──────────────────────▶│ ⑦ POST /v1/purchases/{orderID}/cancel
  109. │ ├───────────────────────────────▶│ 보류 취소 / 확정분 회수
  110. ```
  111. ### 3-2. 사전 조건
  112. 1. DPOT 운영팀과 제휴 계약 → **게임 등록** (게임 코드 발급 + 수수료율 설정은 DPOT 측에서 수행)
  113. 2. 개발자 계정 승인 (섹션 1-1) 후 앱 생성 — scope 는 `write:purchases read:purchases read:channels`
  114. 3. **결제 등록/취소는 OAuth2 앱 토큰 전용**입니다. PAT 으로는 조회(`read:purchases`)만 가능합니다.
  115. ### 3-3. Step 1 — 후원 코드 입력과 검증
  116. 게임 설정 화면 등에 "크리에이터 후원 코드" 입력란을 제공하세요 (4~7자 영문+숫자, 대소문자 무관).
  117. 입력 시점에 `GET /v1/channels/{code}` 로 검증해 유저에게 즉시 피드백합니다.
  118. | 응답 | 처리 |
  119. |---|---|
  120. | `200` + `active: true` | 코드 저장, 이후 결제 보고에 사용 |
  121. | `200` + `active: false` | "사용할 수 없는 코드" 안내 — 결제 보고가 거부되므로 저장하지 않음 |
  122. | `404` | "존재하지 않는 코드" 안내 |
  123. ### 3-4. Step 2 — 결제 보고 (`POST /v1/purchases`)
  124. 유저의 인앱 결제가 **스토어에서 확정된 후** 서버에서 보고합니다.
  125. | 필드 | 규칙 |
  126. |---|---|
  127. | `orderID` | **마켓 거래 ID 원문 그대로** — Google `GPA.xxxx-xxxx-xxxx-xxxxx`, Apple Transaction ID 등. 자체 채번/가공 금지 (분쟁 시 영수증 대조 근거) |
  128. | `marketplace` | 1=구글, 2=애플, 3=MS, 4=갤럭시, 5=원스토어, 6=기타 |
  129. | `gameCode` | DPOT 이 발급한 게임 코드 |
  130. | `orderPrice` | 유저 실결제 금액 (KRW) |
  131. | `productID` | 인앱 상품 SKU — **전달 권장** (DPOT 측 금액 검증에 사용) |
  132. | `channelCode` | 유저가 입력한 후원 코드 |
  133. ```bash
  134. curl -X POST https://api.dpot.live/v1/purchases \
  135. -H "Authorization: Bearer {access_token}" \
  136. -H "Content-Type: application/json" \
  137. -d '{
  138. "channelCode": "ABC123",
  139. "orderID": "GPA.3312-8767-0710-71943",
  140. "marketplace": 1,
  141. "gameCode": "XXXXXX",
  142. "orderPrice": 11000,
  143. "productID": "diamond_1000"
  144. }'
  145. ```
  146. `201` 응답의 `status` 는 항상 `Pending` 이며, 수수료는 `confirmDueAt`(등록 +14일, 주말·공휴일 포함)
  147. 에 자동 확정되어 채널에 입금됩니다. 응답의 `commissionAmount` 는 채널 적립 예정액입니다.
  148. **멱등성과 재시도** — 동일 (`marketplace`, `orderID`) 조합은 1회만 등록됩니다.
  149. | 상황 | 대응 |
  150. |---|---|
  151. | 타임아웃 / 네트워크 오류 / `5xx` | **동일 페이로드로 재시도해도 안전** (이미 등록됐다면 `409`) |
  152. | `409 Purchase.Duplicate` | 이미 등록된 주문 — **성공으로 간주**하고 종료 |
  153. | `400` / `403` / `404` | 재시도 금지 — 페이로드·scope·게임 코드 점검 |
  154. ### 3-5. Step 3 — 환불 시 취소 보고 (`POST /v1/purchases/{orderID}/cancel`)
  155. 스토어 환불을 확인하면 **반드시** 취소를 보고하세요. 미보고 시 환불된 결제의 수수료가
  156. 크리에이터에게 확정 지급되며, 정산 대사에서 불일치로 기록됩니다.
  157. ```bash
  158. curl -X POST "https://api.dpot.live/v1/purchases/GPA.3312-8767-0710-71943/cancel" \
  159. -H "Authorization: Bearer {access_token}"
  160. ```
  161. | 케이스 | 응답 |
  162. |---|---|
  163. | 보류 중(14일 내) 취소 | 적립 자체가 소멸 — `recoveredAmount: 0`, `shortfallAmount: 0` |
  164. | 확정 후 취소 | 채널 적립분 회수 — `recoveredAmount` 에 회수액, 잔액 부족분은 `shortfallAmount` |
  165. | `409 Purchase.AlreadyCanceled` | 이미 처리됨 — 성공으로 간주 |
  166. 취소는 **등록한 앱만** 가능합니다. 동일 `orderID` 가 여러 마켓에 있으면 `?marketplace=` 를 지정하세요.
  167. ### 3-6. Step 4 — 정산 대사
  168. `GET /v1/purchases?from=&to=&status=&page=&size=` 로 기간별 등록 내역을 받아
  169. 자체 결제 DB 와 주기적으로 대조하세요 (일 1회 권장).
  170. 누락 건은 추가 보고하고, 금액 불일치는 DPOT 운영팀과 협의합니다.
  171. 계약에 따라 월 정산 시 스토어 매출 리포트 제출이 요구될 수 있습니다.
  172. ### 3-7. 토큰 관리 구현 예시
  173. `access_token` 은 **1시간(3600초)** 유효합니다. 매 호출마다 발급하지 말고 캐시 후
  174. 만료 60초 전에 재발급하세요. (토큰 발급 호출도 rate limit 에 포함됩니다.)
  175. C# (.NET):
  176. ```csharp
  177. public sealed class DpotApiClient(HttpClient http, string clientID, string clientSecret)
  178. {
  179. private string? _token;
  180. private DateTime _expiresAt;
  181. private async Task<string> GetTokenAsync()
  182. {
  183. if (_token is not null && DateTime.UtcNow < _expiresAt.AddSeconds(-60))
  184. {
  185. return _token;
  186. }
  187. var res = await http.PostAsync("https://api.dpot.live/oauth/token", new FormUrlEncodedContent(new Dictionary<string, string>
  188. {
  189. ["grant_type"] = "client_credentials",
  190. ["client_id"] = clientID,
  191. ["client_secret"] = clientSecret,
  192. ["scope"] = "write:purchases read:purchases read:channels"
  193. }));
  194. res.EnsureSuccessStatusCode();
  195. var json = await res.Content.ReadFromJsonAsync<JsonElement>();
  196. _token = json.GetProperty("access_token").GetString()!;
  197. _expiresAt = DateTime.UtcNow.AddSeconds(json.GetProperty("expires_in").GetInt32());
  198. return _token;
  199. }
  200. public async Task<HttpResponseMessage> ReportPurchaseAsync(object payload)
  201. {
  202. var req = new HttpRequestMessage(HttpMethod.Post, "https://api.dpot.live/v1/purchases");
  203. req.Headers.Authorization = new("Bearer", await GetTokenAsync());
  204. req.Content = JsonContent.Create(payload);
  205. return await http.SendAsync(req);
  206. }
  207. }
  208. ```
  209. Node.js:
  210. ```javascript
  211. let cached = { token: null, expiresAt: 0 };
  212. async function getToken() {
  213. if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
  214. const res = await fetch('https://api.dpot.live/oauth/token', {
  215. method: 'POST',
  216. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  217. body: new URLSearchParams({
  218. grant_type: 'client_credentials',
  219. client_id: process.env.DPOT_CLIENT_ID,
  220. client_secret: process.env.DPOT_CLIENT_SECRET,
  221. scope: 'write:purchases read:purchases read:channels'
  222. })
  223. });
  224. const json = await res.json();
  225. cached = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 };
  226. return cached.token;
  227. }
  228. async function reportPurchase(payload) {
  229. const res = await fetch('https://api.dpot.live/v1/purchases', {
  230. method: 'POST',
  231. headers: { 'Authorization': `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
  232. body: JSON.stringify(payload)
  233. });
  234. if (res.status === 409) return { duplicated: true }; // 이미 등록 — 성공 간주
  235. if (!res.ok) throw new Error(`DPOT report failed: ${res.status}`);
  236. return await res.json();
  237. }
  238. ```
  239. ### 3-8. 상태 / 오류 빠른 참조
  240. 결제 보고의 `status`:
  241. | status | 의미 |
  242. |---|---|
  243. | `Pending` | 보류 — `confirmDueAt` 에 자동 확정 예정 |
  244. | `Confirmed` | 확정 — 채널 수수료 입금 완료 |
  245. | `Canceled` | 취소됨 |
  246. 결제 보고 관련 `errors[].code`:
  247. | Code | HTTP | 의미 / 대응 |
  248. |---|---|---|
  249. | `Channel.NotFound` | 404 | 후원 코드 없음 — 유저 입력 재확인 |
  250. | `Channel.Inactive` | 400 | 비활성/탈퇴 채널 — 저장된 코드 해제 안내 |
  251. | `Game.NotFound` | 404 | 게임 코드 오류 — 발급받은 코드 확인 |
  252. | `Game.Inactive` | 400 | 게임 비활성 — DPOT 운영팀 문의 |
  253. | `Game.ApiCommissionNotConfigured` | 400 | 수수료율 미설정 — DPOT 운영팀 문의 |
  254. | `Purchase.Duplicate` | 409 | 이미 등록된 주문 — 성공 간주 |
  255. | `Purchase.NotFound` | 404 | 취소/조회 대상 없음 (타 앱이 등록한 건 포함) |
  256. | `Purchase.AlreadyCanceled` | 409 | 이미 취소됨 — 성공 간주 |
  257. | `Purchase.AmbiguousOrderID` | 400 | 여러 마켓에 동일 orderID — `marketplace` 지정 필요 |
  258. | `Purchase.AppNotActive` | 403 | 앱 정지 상태 — DPOT 운영팀 문의 |
  259. ---
  260. ## 4. API 변경 이력
  261. ### v1.1.0 — 2026-06-11
  262. **결제 보고 (게임사 파트너)**
  263. - `POST /v1/purchases` 추가 — 게임 내 결제 등록, 채널 후원 코드 기반 판매 수수료 보류 적립 (등록 +14일 후 확정)
  264. - `POST /v1/purchases/{orderID}/cancel` 추가 — 결제 취소 (등록한 앱만 가능)
  265. - `GET /v1/purchases/{orderID}` / `GET /v1/purchases` 추가 — 파트너 대사용 조회
  266. - `GET /v1/channels/{code}` 추가 — 채널 후원 코드 사전 검증
  267. - 신규 scope: `write:purchases`(OAuth2 앱 토큰 전용), `read:purchases`, `read:channels`
  268. ### v1.0.0 — 2026-06-05
  269. **Initial Release**
  270. - `GET /v1/members?hasChannel=` 추가 — `hasChannel` 쿼리로 일반/크리에이터/전체 필터
  271. - `GET /v1/products` 추가 (쿠폰 상품의 경우 `coupon` 필드 포함)
  272. - `GET /v1/games/{gameID}/coupons` 추가
  273. - `GET /v1/coupons/codes/{code}` 추가
  274. - `GET /v1/stats/products` 추가
  275. ---
  276. ## 5. 응답 / 오류 코드
  277. ### 정상 응답
  278. 모든 정상 응답은 다음 envelope 으로 감쌉니다.
  279. ```json
  280. {
  281. "success": true,
  282. "data": { ... }
  283. }
  284. ```
  285. ### 오류 응답
  286. 오류는 RFC 7807 ProblemDetails 형식으로 반환됩니다.
  287. ```json
  288. {
  289. "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  290. "title": "Member not found",
  291. "status": 404,
  292. "detail": "ID 12345 에 해당하는 회원이 없습니다.",
  293. "errors": [
  294. { "code": "Member.NotFound", "description": "..." }
  295. ]
  296. }
  297. ```
  298. ### HTTP Status Code
  299. | Status | 의미 | 처리 가이드 |
  300. |---|---|---|
  301. | `200` | 성공 | — |
  302. | `400` | 요청 파라미터 검증 실패 | `errors[].description` 확인 후 재호출 |
  303. | `401` | 인증 토큰 없음 / 만료 | 토큰 재발급 |
  304. | `403` | scope 부족 또는 권한 거부 | 앱의 scope 설정 확인 |
  305. | `404` | 대상 리소스 없음 | 식별자 재확인 |
  306. | `409` | 충돌 (중복/상태 불일치) | 현재 상태 재조회 후 재시도 |
  307. | `429` | Rate limit 초과 | `Retry-After` 초 만큼 대기 |
  308. | `500` | 서버 내부 오류 | DPOT 운영팀에 문의 |
  309. ### 비즈니스 오류 코드
  310. `errors[].code` 필드로 세분화된 코드가 제공됩니다.
  311. | Code | HTTP | 의미 |
  312. |---|---|---|
  313. | `Auth.MissingToken` | 401 | Authorization 헤더 누락 |
  314. | `Auth.InvalidToken` | 401 | 토큰 형식 오류 / 만료 / 위조 |
  315. | `Auth.ScopeRequired` | 403 | 토큰 scope 부족 |
  316. | `Member.NotFound` | 404 | 회원 없음 |
  317. | `Game.NotFound` | 404 | 게임 없음 |
  318. | `Product.NotFound` | 404 | 상품 없음 |
  319. | `Coupon.NotFound` | 404 | 쿠폰 코드 없음 |
  320. | `Coupon.InvalidCode` | 400 | 코드 형식 오류 (길이/문자) |
  321. | `Channel.NotFound` | 404 | 채널 후원 코드 없음 |
  322. | `Channel.Inactive` | 400 | 비활성/탈퇴 채널 |
  323. | `Game.Inactive` | 400 | 비활성 게임 |
  324. | `Game.ApiCommissionNotConfigured` | 400 | 결제 보고 수수료율 미설정 게임 |
  325. | `Purchase.Duplicate` | 409 | 결제 보고 중복 (marketplace + orderID) |
  326. | `Purchase.NotFound` | 404 | 결제 보고 없음 |
  327. | `Purchase.AlreadyCanceled` | 409 | 이미 취소된 결제 보고 |
  328. | `Purchase.AmbiguousOrderID` | 400 | 여러 마켓에 동일 orderID — marketplace 지정 필요 |
  329. | `Purchase.AppNotActive` | 403 | 앱 비활성 상태 |
  330. | `RateLimit.Exceeded` | 429 | 호출량 초과 |
  331. """
  332. });
  333. options.DocInclusionPredicate((docName, apiDesc) =>
  334. {
  335. var groupName = apiDesc.GroupName ?? "internal";
  336. return docName == groupName;
  337. });
  338. options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
  339. {
  340. Type = SecuritySchemeType.Http,
  341. Scheme = "bearer",
  342. BearerFormat = "JWT",
  343. Description = "JWT / OAuth2 access_token / Personal Access Token (dpot_pat_xxx) 을 입력하세요."
  344. });
  345. options.AddSecurityRequirement(document => new() { [new OpenApiSecuritySchemeReference("Bearer", document)] = [] });
  346. // OpenAPI 3.x servers field — Scalar / Swagger UI 의 server selector + "Try it out" base URL.
  347. // 환경별 (PROD=api.dpot.live / DEV=dev-api.dpot.live / LOCAL=localhost:4000) 로 자동 채워짐.
  348. options.AddServer(new OpenApiServer
  349. {
  350. Url = settings.App.ApiURL,
  351. Description = $"{settings.App.Name} ({settings.App.ApiURL})"
  352. });
  353. });
  354. return services;
  355. }
  356. }