# 韩国 KakaoPay 支付 API · 商务对接文档

> **服务地址**: `https://cha.nerver.cc`  
> **协议**: HTTPS / JSON  
> **字符编码**: UTF-8

---

## 端点

```
POST https://cha.nerver.cc/api/v1/kakao-pay            # 同步 JSON
POST https://cha.nerver.cc/api/v1/kakao-pay/stream     # SSE 实时进度流
```

输入用户的 ChatGPT JWT, 返回**韩国 KakaoPay 支付二维码 URL** + tid + 过期时间。  
内置 approve POOL (50 并发持续打 × 累计 2000 次抢风控通过) + 整轮失败可重试。

| 场景 | 典型耗时 |
|---|---|
| approve 第一轮就通过 | 60~90 秒 |
| 用户 token 已失效 | 3~5 秒 |
| approve 全部 blocked / 网络异常 | 120~300 秒 |

> NaverPay 请改用 [/api/v1/naver-pay](/docs/naver-pay.md). 两个端点共用同一份 `kr_*` 后台配置 (token / 代理 / 并发池).

---

## 鉴权

请求头携带服务商发放的 token (任一匹配即放行):

```
Authorization: Bearer <kr_business_token>
```

或使用 query 参数 `?key=<kr_business_token>`。

> 后台同时支持 `kr_service_token` (内部页面用) 和 `kr_business_token` (商务对接用), 任一匹配即可。两者全空 = 不鉴权。  
> 鉴权失败返回 HTTP 401。

---

## 请求

`Content-Type: application/json`

```json
{
  "token": "eyJhbGciOiJSUzI1NiIs...(用户的 ChatGPT JWT)",
  "promoId": "plus-1-month-free",
  "poolKeep": 50,
  "poolMax": 2000,
  "retryCount": 1
}
```

| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `token` | string | ✅ | — | 用户的 ChatGPT AC token (JWT, 以 `eyJ` 开头, 长度 ≤ 8192) |
| `promoId` | string | ❌ | 后台 `promo_id` | 优惠 ID |
| `poolKeep` | number | ❌ | 后台 `kr_pool_keep` (50) | approve POOL 持续并发槽数, 1~50 |
| `poolMax` | number | ❌ | 后台 `kr_pool_max` (2000) | approve POOL 累计尝试上限, 1~2000 |
| `retryCount` | number | ❌ | 后台 `kr_retry_count` (1) | 总体失败重试次数 (整轮重跑), 0~5 |

> 不传 / 留空 = 沿用后台 settings, 推荐做法。

---

## 成功响应 (HTTP 200)

```json
{
  "ok": true,
  "qr_url": "https://online-payment.kakaopay.com/bridge/mobile-pc/reseller/subscription/issue/58325366c833d13336132555830638102136f96e5b1c1943022ac351746cd45d3",
  "qr_https_url": "https://online-pay.kakaopay.com/pay/r1/58325366c833d13336132555830638102136f96e5b1c1943022ac351746cd45d",
  "ios_app_url": "kakaotalk://kakaopay/pg?payweb_talk_min_version=11.3.0&payweb_url=...&url=https://online-pay.kakaopay.com/pay/r1/...",
  "aos_app_url": "intent://kakaopay/pg?...",
  "bridge_page_url": "https://online-payment.kakaopay.com/bridge/pc/reseller/subscription/issue/58325366c833d13336132555830638102136f96e5b1c1943022ac351746cd45d",
  "kakaopay_bridge_url": "https://online-payment.kakaopay.com/bridge/pc/reseller/subscription/issue/58325366c833d13336132555830638102136f96e5b1c1943022ac351746cd45d",
  "stripe_redirect_url": "https://pm-redirects.stripe.com/return/...",
  "kakao_tid": "ta2c00b41aea3bdb8394",
  "expired_at": 1781301960,
  "expired_iso": "2026-06-12T11:46:00.000Z",
  "request_id": "req_8d03870548f31b7e",
  "duration_ms": 68712
}
```

| 字段 | 说明 |
|---|---|
| `ok` | `true` 表示成功 |
| `qr_url` | **★ PC 网页二维码实际编码内容** — `https://online-payment.kakaopay.com/bridge/**mobile-pc**/reseller/subscription/issue/{hash}`. 用 KakaoTalk app 扫码会被识别为付款请求, 自动唤起本机付款流程. 直接用 qrcode 库渲染这个 URL 即可 |
| `qr_https_url` | iOS Safari fallback (`online-pay.kakaopay.com/pay/r1/{hash}`), 从 `ios_app_url` 解出 `&url=` 参数. 仅在手机浏览器内点击能唤起 KakaoTalk, **不**适合用于 PC 二维码 |
| `ios_app_url` | iOS / Android 移动端深链接 (`kakaotalk://kakaopay/pg?...`), 移动端浏览器内点击可直接唤起 KakaoTalk |
| `aos_app_url` | Android Intent 链接 (`intent://...#Intent;scheme=kakaotalk;...;end`) |
| `bridge_page_url` | KakaoPay PC bridge 页面 URL (路径 `/bridge/pc/`, 用浏览器打开时显示 QR 图); **不是**扫码内容 |
| `kakaopay_bridge_url` | 同 `bridge_page_url` (向后兼容字段) |
| `stripe_redirect_url` | Stripe pm-redirects 中间链 (最保鲜, 备用) |
| `kakao_tid` | KakaoPay 交易 ID, 后续对账用 |
| `expired_at` | QR 过期时间 (UTC 秒), 通常 ~20 分钟有效 |
| `expired_iso` | 过期时间 (ISO 8601) |
| `request_id` | 本次请求追踪 ID |
| `duration_ms` | 服务端处理耗时 (毫秒) |

> **渲染推荐**: 用任意 qrcode 库 (qrcode.js / qrcode-generator / PIL QRCode) 把 `qr_url` 直接编码成 QR PNG / SVG, KakaoTalk 扫码后会唤起 in-app 浏览器自动完成付款.  
> **不要**直接把 `bridge_page_url` (路径 `/bridge/pc/`) 当 QR 内容 — 那是 PC 浏览器打开的网页地址, 用户扫码扫到的话只是再打开一个网页, 不能唤起 app.  
> 验证来源: PC 网页上的 SVG QR 反相解码后 → 路径是 `bridge/mobile-pc/`, hash 末尾比 PC 页面 URL 多一位 checksum 字符.

---

## 失败响应

#### token 失效 (HTTP 200, body.ok=false)

```json
{ "ok": false, "reason": "token-invalidated", "message": "TOKEN 已失效, 请重新获取", "request_id": "req_xxx", "duration_ms": 3534 }
```

#### approve 风控 blocked

```json
{ "ok": false, "reason": "approve-blocked", "message": "该账号已被 OpenAI 风控, 请更换账号重试", "error": "approve attempts exhausted", "attempts": 2, "request_id": "req_xxx", "duration_ms": 67841 }
```

#### 账号已支付

```json
{ "ok": false, "reason": "already-paid", "message": "用户已支付, 无需重复创建订单" }
```

#### 鉴权失败 (HTTP 401)

```json
{ "ok": false, "reason": "unauthorized" }
```

#### 限流 (HTTP 429)

```json
{ "ok": false, "reason": "rate-limited", "retry_after": 60 }
```

#### 服务端并发已满 (HTTP 503)

```json
{ "ok": false, "reason": "concurrency-busy", "message": "当前并发过高请稍后重试" }
```

#### 功能维护 (HTTP 503)

```json
{ "ok": false, "reason": "feature-disabled", "message": "KakaoPay 服务已在后台禁用" }
```

---

## reason 字段全集

| reason | 含义 | 是否值得换 token 重试 |
|---|---|---|
| `ok` | 成功 | — |
| `empty-token` | 没传 token | 否 |
| `invalid-token-format` | token 不是 JWT 格式 | 否 |
| `jwt-expired` | JWT 本地校验已过期 | 是 |
| `token-invalidated` | OAI 返回 401 / token 被撤销 | 是 |
| `already-paid` | 用户已是付费用户 | 否 |
| `risk-blocked` | OpenAI 账号风控 (HTML 拦截页) | 是 |
| `checkout-failed` | OAI checkout 接口非 200 | 重试 / 换 token |
| `region-failed` | Stripe tax_region 失败 | 重试 |
| `pm-unavailable` | Stripe 没暴露 KakaoPay | 否 (账号区域可能不支持) |
| `snapshot-failed` | OAI snapshot 接口失败 | 重试 |
| `confirm-error` | Stripe confirm 业务错误 | 重试 |
| `confirm-failed` | Stripe confirm 网络异常 | 重试 |
| `approve-blocked` | approve POOL 全部 blocked | 换 token |
| `no-redirect` | approve 后 Stripe 未返回 redirect | 重试 |
| `redirect-chain-failed` | NicePay 8 跳链任一环节失败 | 重试 |
| `concurrency-busy` | 服务端并发已满 | 5~10 秒后重试 |
| `feature-disabled` | KakaoPay 后台禁用 | 联系运营 |
| `unauthorized` | 鉴权失败 | 检查 token |
| `rate-limited` | 触发 RPM | retry-after 后重试 |
| `server-error` | 内部异常 | 重试 |

---

## SSE 流式接口

```
POST /api/v1/kakao-pay/stream
```

请求体 / 鉴权同 `/api/v1/kakao-pay`, 返回 `text/event-stream`:

```
event: hello
data: {"request_id":"req_xxx","pool_keep":50,"pool_max":2000,"retry_count":1,"queue_length":0,"inflight":1,"capacity":3}

event: progress
data: {"type":"attempt-start","attempt":1,"max":2}

event: progress
data: {"type":"step","attempt":1,"step":"warmup"}

...

event: progress
data: {"type":"approve-tick","slot":50,"max":2000,"blocked":42,"exception":1,"other":0,"elapsed_ms":12345}

event: progress
data: {"type":"approve-hit","slot":127}

event: progress
data: {"type":"step","attempt":1,"step":"polling"}

event: progress
data: {"type":"step","attempt":1,"step":"redirect-chain"}

event: progress
data: {"type":"step","attempt":1,"step":"bridge"}

event: progress
data: {"type":"attempt-end","attempt":1,"ok":true,"reason":""}

event: done
data: {完整响应对象, 同 /api/v1/kakao-pay 同步返回}
```

阶段 `step` 取值: `warmup`, `sentinel`, `checkout`, `warmup-account`, `region`, `snapshot`, `confirm`, `approve`, `polling`, `redirect-chain`, `bridge`.

额外 progress 事件:
- `approve-tick` — 每 50 次 approve 推送一次, 含 `slot` / `max` / `blocked` / `exception` / `other` / `elapsed_ms`
- `approve-hit` — 命中那一刻推送, 含 `slot`

---

## 流程图

```
┌──────────────────────────────────────────────────────┐
│   STEP 0    chatgpt.com warmup CF cookie             │
│   STEP 0b   sentinel frame/sdk/ping                  │
│   STEP 1    POST OAI /payments/checkout (KR/KRW)     │
│             → cs_live_xxx + processor_entity         │
│   STEP 1b   GET /checkout HTML + 9 个账户预热 GET    │
│   STEP 1c   GET stripe /v1/elements/sessions         │
│   STEP 2    POST stripe payment_pages tax_region=KR  │
│             → 校验 pm 可用                            │
│   STEP 3    POST OAI snapshot (KR 韩国身份)          │
│   STEP 4    POST stripe payment_pages/confirm        │
│             type=kakao_pay                           │
│   STEP 5    POST OAI approve POOL                    │
│             50 并发槽 × 累计 2000 次, race + stopAll │
│   STEP 6    GET stripe payment_pages 轮询 redirect   │
│   STEP 7    跟 NicePay 8 hop (web.nicepay.co.kr)     │
│             → KakaoPay bridge URL                    │
│   STEP 8    GET pay-api-gw.kakaopay.com bridge JSON  │
│             → tid + expired + 移动端 app_url + hash  │
└──────────────────────────────────────────────────────┘
```

---

## 接入示例 (Node.js)

```js
const res = await fetch("https://cha.nerver.cc/api/v1/kakao-pay", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "authorization": "Bearer " + process.env.KR_BUSINESS_TOKEN,
  },
  body: JSON.stringify({ token: userJwt }),
});
const data = await res.json();
if (!data.ok) {
  console.error(`KakaoPay 失败 (${data.reason}): ${data.message}`);
  return;
}
console.log("QR 内容:", data.qr_url);
console.log("过期时间:", data.expired_iso);
```

---

## 接入示例 (Python)

```python
import requests, qrcode
res = requests.post(
    "https://cha.nerver.cc/api/v1/kakao-pay",
    headers={
        "content-type": "application/json",
        "authorization": f"Bearer {KR_BUSINESS_TOKEN}",
    },
    json={"token": user_jwt},
    timeout=300,
).json()

if not res["ok"]:
    raise RuntimeError(f"KakaoPay fail: {res['reason']} {res.get('message')}")

img = qrcode.make(res["qr_url"])
img.save("kakaopay.png")
print("tid:", res["kakao_tid"], "expires:", res["expired_iso"])
```

---

## FAQ

**Q: 同一个用户能并发请求吗?**  
A: 不建议, 同 token 并发会撞 OAI 风控。推荐顺序调用, 间隔 ≥ 5 秒。

**Q: `qr_url` 能直接给用户在浏览器打开吗?**  
A: `qr_url` (`bridge/mobile-pc/...`) 应该只用作 QR 编码内容, 不要让用户直接点。需要让用户在 PC 浏览器看 QR 图请用 `bridge_page_url` (`bridge/pc/...`)。

**Q: 二维码有效期多久?**  
A: 通常 20 分钟 (具体看 `expired_at`)。过期后需要重新请求 API 拿新二维码。
