# IAA Cohort Console — 客户端接入规范（CLIENT API）

> **Canonical**：本文件是客户端 / SDK / 前端工程师对接的权威说明。  
> 线上控制台：https://daynlab.cc/  
> 文档页：https://daynlab.cc/docs/  
> 备用（HTTP IP，过渡期）：http://178.79.148.14/  
> 仓库：https://github.com/cybx-gz/iaa-cohort-console  

版本：`v1.14.1`（路径前缀 `/api/v1/…`；Additive：game_id 写入展开模板 `{game_id}`）  
最后更新：2026-10-15（Asia/Shanghai）· 域名 HTTPS

---

## 1. Base URL、版本与兼容承诺

### 1.1 Base URL

| 环境 | Base URL |
|------|----------|
| 生产（当前） | `https://daynlab.cc` |
| 生产备用（过渡期） | `http://178.79.148.14`（仍可用；新接入请用域名 HTTPS） |
| 本机开发 | `http://127.0.0.1:3050`（API）+ 静态页另开 |

所有下文路径均相对于 Base URL。Nginx 将 `/api/` 反代到本机 API（`127.0.0.1:3050`）；`daynlab.cc` 已启用 HTTPS（Let’s Encrypt）。

### 1.2 版本策略

| 路径 | 用途 | 稳定性 |
|------|------|--------|
| **`/api/v1/events`** | 客户端事件上报（ingest） | **稳定契约**；客户端只应依赖此路径 |
| `/api/events`、`/v1/events` | 与上等价的别名 | 兼容保留，新集成请用 `/api/v1/events` |
| **`/api/v1/fx`**（及 `/api/fx`） | 公开只读汇率 | **稳定**；无需 Key（客户端可选用） |
| `/api/health` | 公开探活 | **仅** `{ ok: true }`；详情见 `/api/health/detail`（需控制台 token） |
| `/api/projects`、`/api/report`、`/api/regions`、`/api/app-versions`、`/api/presets`、`/api/fx/manual`、`/api/attribution/*` | 控制台 / 报表 / 管理 / 归因 | 需 **`Authorization: Bearer <CONSOLE_TOKEN>`** |
| `/api/v1/attribution/ingest/...` | Unity 点击/展示入库 | **路径令牌**（无 Bearer / 无 Api-Key） |

**客户端（游戏包 / SDK）优先打通：`POST /api/v1/events`；需要本地换算时再接 `GET /api/v1/fx`。**  
报表与项目管理由控制台或服务端拉取，一般不必打进游戏包。

### 1.3 兼容承诺（Compatibility Promise）

在 **同一主版本 `v1`** 内：

| 允许（additive / 非破坏） | 禁止（破坏性，必须升主版本） |
|---------------------------|------------------------------|
| 响应 JSON **新增**字段 | **重命名**已有字段 |
| 请求体接受 **新的可选**字段 | **删除**已有字段或必填变可选再变必填等语义翻转 |
| 新增事件类型别名（归一到已有类型） | 改变已有字段的含义或单位（如 `revenue` 从「展示金额」变为「微单位」） |
| 新增查询参数（有合理默认） | 删除/改名查询参数且无默认兼容 |
| HTTP 成功/失败语义保持下表 | 把原先 200 的成功改成非 2xx 且无过渡期 |

破坏性变更会：

1. 发布 **`/api/v2/…`**（或新主版本文档）；
2. 在本文件 **Changelog / Deprecation** 中标注废弃时间；
3. **`v1` 至少保留 90 天** 并行，再考虑关闭。

客户端解析响应时应 **忽略未知字段**（forward-compatible）。

---

## 2. 认证（Auth）

### 2.1 方式

上报接口必须带项目 API Key，二选一（等价）：

```http
X-Api-Key: <apiKey>
```

或：

```http
Authorization: Bearer <apiKey>
```

- Header 名大小写不敏感；值两侧空白会 trim。
- **不要**把 Key 放进 URL query。
- CORS 已允许：`Content-Type`、`X-Api-Key`、`Authorization`、`X-Console-Token`。

### 2.1.1 控制台鉴权（CONSOLE_TOKEN）

报表、项目列表/登记、预设、地区/版本枚举、FX 人工覆盖等 **控制台接口** 需要环境变量 **`CONSOLE_TOKEN`**（或 `CONSOLE_PASSWORD` 作别名）：

```http
Authorization: Bearer <CONSOLE_TOKEN>
```

也可选：`X-Console-Token: <CONSOLE_TOKEN>`。

- **未配置** `CONSOLE_TOKEN` 时，上述接口一律 **401**（不默认开放）。
- 浏览器控制台：登录页粘贴 token，写入 `localStorage`，之后自动带 Bearer。
- **客户端事件上报仍只用项目 `X-Api-Key`**，不要也不应使用 `CONSOLE_TOKEN`。
- Token **只放服务器 env / `.env.console`**，勿写入 git、任务 outputs 或前端源码。

产品说明：[`CONSOLE_SECURITY.md`](./CONSOLE_SECURITY.md)。

### 2.2 登记项目 → 拿到 `id` + `apiKey`

```http
POST /api/projects
Authorization: Bearer <CONSOLE_TOKEN>
Content-Type: application/json

{"name":"我的新游戏","note":"可选备注","tags":["prod","cn"]}
```

`tags` 可选：字符串数组或逗号分隔串；服务端归一为小写 slug（`[a-z0-9_-]`），去重，最多 16 个、每项 ≤ 32 字。常见值：`prod` / `test` / `cn` / `gp`。

**成功：`201`**

```json
{
  "id": "g_my_new_game",
  "name": "我的新游戏",
  "note": "可选备注",
  "tags": ["prod", "cn"],
  "apiKey": "ak_<32 hex chars>",
  "createdAt": "2026-10-12T08:00:00.000Z"
}
```

| 字段 | 说明 |
|------|------|
| `id` | 项目 ID，形如 `g_…`；报表用 `project_id` |
| `tags` | 字符串数组（可能为空 `[]`） |
| `apiKey` | **仅在登记响应中完整返回**；请立即安全保存 |
| `createdAt` | ISO8601 UTC |

失败示例：

| HTTP | `error` | 场景 |
|------|---------|------|
| 400 | `invalid_json` | Body 非 JSON |
| 400 | `name_required` | 名称为空 |
| 400 | `name_too_long` | 名称 > 80 字 |
| 401 | `unauthorized` | 缺少/无效 CONSOLE_TOKEN |

控制台侧栏「登记项目」走同一接口（已登录后自动带 Bearer；可填标签）。

### 2.3 列表与按标签过滤

```http
GET /api/projects
Authorization: Bearer <CONSOLE_TOKEN>
GET /api/projects?tag=prod
GET /api/projects?exclude_tag=test
```

返回数组，每项含 `id` / `name` / `note` / `tags` / `createdAt` / `apiKeyHint`（末 6 位），**不含**完整 `apiKey`。

| Query | 说明 |
|-------|------|
| `tag` | 只返回含该标签的项目 |
| `exclude_tag` | 排除含该标签的项目（控制台「隐藏测试」也可在前端按 `test` 过滤） |

> `GET /api/projects/keys` **默认关闭**（404）。仅当服务器显式 `ENABLE_PROJECT_KEYS=1` **且** 带有效控制台 Bearer 时才返回完整 Key；生产勿开启。  
> 控制台 `GET/POST/PATCH /api/projects` 需 `CONSOLE_TOKEN` Bearer（上报仍走 `X-Api-Key`）。

### 2.3.1 更新项目（含标签）`PATCH`

```http
PATCH /api/projects/<id>
Authorization: Bearer <CONSOLE_TOKEN>
Content-Type: application/json

{"tags":["prod","test"]}
```

也可 `PATCH /api/projects`，body 带 `id`。可选字段：`tags` / `name` / `note`（只更新出现的字段）。

**成功：`200`** — 返回公开项目对象（含 `tags`，无完整 `apiKey`）。

| HTTP | `error` | 场景 |
|------|---------|------|
| 400 | `id_required` | 未给 id |
| 400 | `name_required` / `name_too_long` | 改名非法 |
| 401 | `unauthorized` | 缺少/无效 CONSOLE_TOKEN |
| 404 | `project_not_found` | 未知 id |

### 2.4 Demo Keys（验收用）

| project `id` | 名称 | `apiKey` |
|--------------|------|----------|
| `g_puzzle_101` | 方块迷城 | `ak_demo_puzzle_101_v1` |
| `g_idle_208` | 挂机矿工 | `ak_demo_idle_208_v1` |
| `g_merge_315` | 合成农场 | `ak_demo_merge_315_v1` |

生产游戏请自行登记，**不要**把 demo key 打进正式包的长期配置。

---

## 3. 事件上报 `POST /api/v1/events`

### 3.1 请求

```http
POST /api/v1/events
Content-Type: application/json
X-Api-Key: <apiKey>
```

Body 三种形态均合法：

1. **单条对象**
2. **数组**（推荐批量）
3. **`{"events":[ … ]}` 包装**

单请求最多 **500** 条；Body 上限约 **2 MiB**。

### 3.2 通用字段

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `event` | string | 是* | 事件类型；也接受 `event_type` / `type` |
| `user_id` | string | 是 | Cohort 主关联键，最长 128；**推荐 = `internal_id`**。详规范 §3.2.1 |
| `ad_id` | string | 强烈建议（有则必报） | GAID / IDFA；别名 `gaid`/`idfa`/`advertising_id`；最长 128 |
| `internal_id` | string | 强烈建议 | 不靠登录的自研 ID；别名 `player_id`；最长 128 |
| `fingerprint_id` | string | 可选 | **仅**重装仍稳的指纹；无效则不要传；最长 128 |
| `ts` | number \| string | 建议 | 事件时间；缺省用服务器当前时间。也接受 `timestamp` |
| `region` | string | 否 | 国家/地区码，建议 ISO 3166-1 alpha-2（如 `US`）；入库会 upper + 截断 8 |
| `country` | string | 否 | 与 `region` 等价；仅当未传 `region` 时使用 |
| `project_id` | string | 否 | 若传入必须与 API Key 所属项目一致，否则该条失败 |
| `app_version` | string | 强烈建议 | 客户端版本号，最长 32；别名 `version` / `appVersion`。事件行永久落库；用户档案保留最近一次非空值。报表筛选以**事件上的版本**为准 |

\* `event` 经归一化后必须属于下表。


### 3.2.1 用户标识：尽量双报（报表优先）

服务端**不发号**。报表侧要能同时分析「广告身份」与「自研身份」，因此客户端**能拿到的 1 和 2 应尽量都上报**，不要二选一丢掉。

| 档 | 字段（建议） | 含义 | 上报要求 |
|----|--------------|------|----------|
| **1** | `ad_id`（别名 `gaid` / `idfa` / `advertising_id`） | 广告标识：Android **GAID**，iOS 授权下 **IDFA** | **有则必报** |
| **2** | `internal_id`（别名 `player_id`） | **不靠登录**的自研发号 / 中台非登录身份，本地持久化 | **有则必报**（通常总能有） |
| **3** | `fingerprint_id` | **仅**删重装后仍能认老用户的指纹 | **确认有效才加**；做不到则**不要传、不要实现假指纹** |

兼容字段 **`user_id`（必填）**：

- 继续作为当前 cohort 主关联键（最长 128）。
- **推荐恒等于 `internal_id`**（自研身份），保证无广告 ID 时链路不断。
- 同时把 `ad_id` / `internal_id`（及有效时的 `fingerprint_id`）一并放进同一条事件；服务端会落库，供报表双口径使用。

约定：

- 同一安装生命周期内：`internal_id` 稳定；`ad_id` 随系统授权变化时可更新，但不要把 `user_id` 改来改去。
- 禁止用「仅本地 UUID、卸重装即丢」冒充 `fingerprint_id`。
- 忽略本规范未列出的弱特征拼接。

### 3.3 时间戳 `ts`

| 输入 | 解释 |
|------|------|
| 数字 `< 1e12` | **Unix 秒** → 转毫秒 |
| 数字 `≥ 1e12` | **Unix 毫秒** |
| ISO8601 字符串 | 如 `"2026-10-05T08:00:00Z"` |
| 缺省 / 非法 | 使用服务器 `Date.now()` |

**建议客户端始终显式传 `ts`（UTC）**，避免设备时区与时钟漂移导致 cohort 错位。

### 3.4 事件类型与字段

归一化别名（大小写不敏感，`-` → `_`）：

| 写入类型 | 接受的别名 |
|----------|------------|
| `install` | `install`, `first_open`, `firstopen` |
| `app_open` | `app_open`, `session`, `open`, `appopen` |
| `ad_revenue` | `ad_revenue`, `ad_rev`, `revenue`, `iap_ad` |
| `custom` | `custom`, `custom_event`, `customevent` |

#### `install` / `first_open`

> **强烈建议：首次安装只发一条 `install`，并把下列字段一次带齐。**  
> 服务端当前**不会**因缺这些可选字段而 400（保持已接入客户端兼容）；但缺字段会直接导致档案空白、控制台地区 `XX`、版本筛选无效。

| 字段 | 必填 / 强烈建议 | 说明 |
|------|-----------------|------|
| `user_id` | ✓ 必填 | Cohort 主关联键；**推荐 = `internal_id`** |
| `internal_id` | **强烈建议** | 不靠登录的自研 ID（别名 `player_id`）；与 `user_id` 同值即可 |
| `ad_id` | **强烈建议（有则必报）** | GAID / IDFA；别名 `gaid`/`idfa`/`advertising_id` |
| `ts` | **强烈建议** | 安装时刻（UTC ms 或 ISO8601）；也可用 `install_ts` 覆盖写入的安装时间 |
| `region` / `country` | **强烈建议** | ISO 3166-1 alpha-2（如 `US`）；写入用户档案；空则控制台归一为 `XX` |
| `app_version` | **强烈建议** | 客户端版本号（别名 `version` / `appVersion`）；事件行永久落库，供版本筛选 |

**最小 JSON 示例（推荐照抄字段名）：**

```json
{"event":"install","user_id":"<internal_id>","internal_id":"<internal_id>","ad_id":"<gaid/idfa>","ts":1234567890123,"region":"US","app_version":"1.2.3"}
```

**缺 `install`、仅靠 `app_open` 建档的后果：**

- 若从不发 `install`、只靠首条 `app_open` 自动建档，则用户档案上的 `region` / `ad_id` / `app_version` 等常为空；
- 控制台地区列表与地区轴会把空地区归一为 **`XX`**；
- 版本下拉 / `app_version` 筛选对该用户无效或对不上；
- 因此请在安装完成时主动上报完整 `install`，不要依赖后续 `app_open` 补档。

**服务端行为：**

- `users` 表写入/更新：`install_ts` 取**最早**一次；
- 插入一条 `install` 事件；
- **同时**插入一条同 `ts` 的 `app_open`（计为 D0 活跃）。

重复上报同一 `user_id` 的 install：**安全**；只会把 `install_ts` 往更早推，并追加事件行。

#### `app_open` / `session`

| 字段 | 必填 | 说明 |
|------|------|------|
| `user_id` | ✓ | |
| `ts` | 建议 | 打开/会话开始时间 |
| `region` / `country` | 建议 | 用户尚无档案时会当作安装创建 |

**服务端行为：**

- 若用户不存在，用本次 `ts` **自动建档为安装**（兼容兜底；**不要**把这当作正式 install——档案字段易空，见上节「缺 install 的后果」）；
- 插入 `app_open`；留存 Dn = 该 day index 上是否有至少一次 `app_open`。

#### `ad_revenue`

| 字段 | 必填 | 说明 |
|------|------|------|
| `user_id` | ✓ | |
| `ts` | 建议 | 展示/结算时间 |
| `revenue` | ✓ | 数值；也接受 `value` |
| `currency` | **强烈建议** | `USD` 或 `CNY`（大小写不敏感）。省略时按 `USD`。也兼容 `EUR`（固定折算）。`revenue` 为**原币金额** |
| `ad_type` | **强烈建议** | 广告类型，归一后三选一：`rewarded`（激励）/ `interstitial`（插屏）/ `banner`（横幅）。无法识别入库为 `unknown` |
| `placement` | 否 | **兼容别名**：若未传 `ad_type`，则用 `placement` 做同样归一；也可同时传（以 `ad_type` 为准）。原始字符串仍落库，最长 64 |
| `region` / `country` | 建议 | |

**`ad_type` 归一表（大小写不敏感）：**

| 规范值 | 接受别名（示例） |
|--------|------------------|
| `rewarded` | `reward`, `rewarded_video`, `rv`, `激励`, `激励视频` |
| `interstitial` | `inter`, `full_screen`, `fs`, `插屏` |
| `banner` | `ban`, `横幅` |

入库同时存原始 `revenue`/`currency` 与按**当时生效 R**（`1 USD = R CNY`，默认/兜底 **6.72**）折算的 `revenue_usd`，以及归一后的 `ad_type`。收益 cohort 按 `revenue_usd` 累计；控制台展示货币可再按当前 R 折出。`unknown` / 横幅不计入「仅插屏和激励」筛选。

成功单条结果可含 `ad_type`（归一后的值）；客户端应忽略未知字段。


#### `custom` / `custom_event`（自定义事件）

用于业务自定义节点（通关、教程完成、某按钮点击等），支持 **单次达成** 与 **可重复发生** 两类分析（见 §5.3）。

| 字段 | 必填 | 说明 |
|------|------|------|
| `user_id` | ✓ | |
| `name` | ✓ | 稳定事件名，1～64 字符；也接受 `event_name` / `custom_event` |
| `ts` | 建议 | 发生时间 |
| `value` | 否 | 可选数值（如关卡号、得分）；非法数字则该条失败 |
| `region` / `country` | 建议 | |

**服务端行为：**

- `event_type` 归一为 `custom`，`name` 永久落库；
- 若用户不存在，用本次 `ts` 自动建档；
- **不做去重**：同一用户多次上报同一 `name` 会多次插入（可重复事件的基础）；
- 单次/重复的差异只在 **报表口径**（`custom_mode`），不在入库。

示例：

```json
{"event":"custom","name":"tutorial_done","user_id":"u1","ts":"2026-10-12T08:10:00Z","region":"US"}
{"event":"custom_event","name":"level_clear","user_id":"u1","ts":"2026-10-12T09:00:00Z","value":3,"region":"US"}
```

### 3.5 成功响应

**HTTP `200`**（只要请求被受理；单条校验失败不把整包打成 4xx）：

```json
{
  "ok": true,
  "project_id": "g_puzzle_101",
  "accepted": 3,
  "total": 3,
  "results": [
    { "ok": true, "event": "install", "user_id": "u1" },
    { "ok": true, "event": "app_open", "user_id": "u1" },
    { "ok": true, "event": "ad_revenue", "user_id": "u1" }
  ]
}
```

| 字段 | 说明 |
|------|------|
| `ok` | 请求级：鉴权与 JSON 通过即为 `true` |
| `accepted` | 本批成功写入条数 |
| `total` | 本批条数 |
| `results` | **仅当 `total ≤ 20` 时返回**；大批量请用 `accepted`/`total` 判断 |

单条失败时该元素形如：

```json
{ "ok": false, "error": "revenue_required", "message": "ad_revenue needs numeric revenue" }
```

### 3.6 请求级错误码

| HTTP | `error` | 含义 |
|------|---------|------|
| 401 | `unauthorized` | 缺少或无效 API Key |
| 400 | `invalid_json` | Body 非 JSON |
| 400 | `empty` | 无事件 |
| 400 | `too_many` | 超过 500 条 |
| 404 | `not_found` | 路径错误 |
| 500 | `internal` | 服务器异常 |

单条 `error`（仍可能整体 HTTP 200）：

| `error` | 含义 |
|---------|------|
| `unknown_event` | 无法识别的 `event` |
| `user_id_required` | 缺 `user_id` |
| `user_id_too_long` | `user_id` > 128 |
| `revenue_required` | `ad_revenue` 无有效数字 |
| `name_required` | `custom` 缺少有效 `name`（1～64） |
| `value_invalid` | `custom` 的 `value` 非数字 |
| `project_mismatch` | body 里 `project_id` 与 Key 不符 |

### 3.7 幂等（Idempotency）

当前 **v1 不做去重**：相同事件重复 POST 会多次插入。

建议客户端：

1. 关键层失败（超时、5xx、无响应）→ **有限次重试**（见 §4）；
2. 对 `install`：重复可接受（取最早 install）；
3. 对 `ad_revenue`：尽量在客户端做本地去重（如 `impression_id`），**未来**服务端可能支持可选 `idempotency_key`（additive，不破坏现契约）；
4. 不要把「HTTP 200 且 `accepted == total`」之外的情况当成成功后再次无脑重放大批历史。

---

## 4. 如何判断成功与重试

### 4.1 成功判定

1. HTTP 状态为 **`200`**；
2. Body `ok === true`；
3. **`accepted === total`**（全部写入）；或按业务接受部分成功（`accepted < total` 时查 `results`）。

控制台验证：

- 侧栏数据源选 **真实入库**；
- 选对应游戏；
- 日期区间覆盖安装日 → 矩阵非空；
- 或 `GET /api/health` 看 `events` / `users` 计数增加。

### 4.2 重试建议

| 情况 | 动作 |
|------|------|
| 网络错误 / 超时 / **5xx** | 指数退避重试（如 1s / 2s / 4s，最多 3～5 次） |
| **401** | **不要重试**；检查 Key / 项目 |
| **400**（`invalid_json` / `too_many` / `empty`） | **不要原样重试**；修正 payload |
| **200** 但部分 `ok:false` | 只重投失败条目（修正字段后） |
| **429**（若未来限流） | 遵守 `Retry-After`；当前未启用 |

客户端应记录 `responseCode` + body 文本便于排查。

---

## 4b. 汇率 API（`GET /api/v1/fx`）

公开只读，**无需 API Key**。别名：`GET /api/fx`。

汇率定义：**`1 USD = R CNY`**，默认 / 兜底 **`R = 6.72`**。

### 成功响应 `200`

```json
{
  "ok": true,
  "usd_to_cny": 6.7249,
  "source": "live",
  "provider": "boc",
  "updated_at": "2026-10-14T18:47:28+08:00",
  "fallback": 6.72
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `ok` | bool | |
| `usd_to_cny` | number | 当前生效 **R** |
| `source` | string | `live` = 近 24h 内（或保留的上次）市价；`manual` = 控制台人工覆盖；`fallback` = 无缓存，用 6.72 |
| `provider` | string | 实时源：`boc`（中国银行中行折算价）\| `frankfurter` \| `er-api`；`manual`/`fallback` 或未知时为 `none`。**additive**，忽略未知即可 |
| `updated_at` | string\|null | 该 R 写入/刷新时间（上海时区 offset） |
| `fallback` | number | 恒为 `6.72` |

**换算**

- USD → CNY：`cny = usd * usd_to_cny`
- CNY → USD：`usd = cny / usd_to_cny`

**服务端行为**

- 约每 **24 小时**按优先级刷新实时源（失败则下一档；全部失败则保留上次成功值；若无历史则 `usd_to_cny=6.72` 且 `source=fallback`、`provider=none`）：
  1. **中国银行**外汇牌价页 [中行折算价](https://www.boc.cn/sourcedb/whpj/index.html)（`provider=boc`）。页面报价单位为 **100 外币兑人民币**，服务端取「美元」行的中行折算价后 **÷100** 得到 R（例：`672.49` → `6.7249`）。
  2. [Frankfurter](https://api.frankfurter.app/latest?from=USD&to=CNY)（`provider=frankfurter`）
  3. [open.er-api.com](https://open.er-api.com/v6/latest/USD) 的 `rates.CNY`（`provider=er-api`）
- 控制台可 `POST /api/fx/manual` `{ "usd_to_cny": <number> }` 写入覆盖（`source=manual`，`provider=none`）；`POST /api/fx/manual/clear` 或 `DELETE /api/fx/manual` 清除后回到 live/fallback。
- 失败不影响其它服务。

```bash
curl -sS "https://daynlab.cc/api/v1/fx"
```

客户端应对 404 / 非 JSON 使用本地 **6.72**。忽略未知字段。不要依赖文档中已废弃的硬编码 7.2。

### 控制台展示与下载

- 报表矩阵统一折到当前展示货币（USD ↔ CNY 可切换）。
- UI 标明展示币种、当前 R、来源（live / manual / fallback）。
- 默认同服务端实时；可本地改 R 仅影响展示与下载。
- 下载 CSV 元数据含 `# currency`、`# usd_to_cny`、`# fx_source`；侧栏可填「下载指定 R」，未填则用当前展示 R。

## 5. 报表 `GET /api/report`

供控制台与本地汇总；**游戏客户端通常不必调用**。

### 5.1 Query 参数（与控制台筛选对齐）

| 参数 | 值 | 默认 / 说明 |
|------|-----|-------------|
| `project_id` | string | **必填**；也接受 `game_id` |
| `report_type` | `retention` \| `revenue` \| `ecpm` \| `custom` | 默认 `retention`；`ecpm` 见 §5.5 |
| `cohort_axis` | `date` \| `region` \| **`ad_type`** | 默认 `date`；`ad_type` 见 §5.6 |
| `granularity` | `day` \| `week` \| `month` \| `year` | 默认 `day`；仅 `cohort_axis=date` 时有意义（`region` / `ad_type` 轴忽略） |
| `day_boundary` | `calendar` \| `rolling24h` | 默认 `calendar`（自然日；日界时区见 `timezone`） |
| `timezone` | `UTC` \| `Asia/Shanghai` | 默认 `UTC`。别名 `tz=UTC\|UTC+8`（`UTC+8` ≡ `Asia/Shanghai`）。非法值 → `400`。仅影响 `calendar` 的日界/安装日/日期轴/成熟度；`rolling24h` 的 dayIndex 不受影响 |
| `date_from` / `date_to` | `YYYY-MM-DD` | 安装日闭区间（按所选 `timezone` 日历日）；缺省近 30 个该时区日 |
| `region` | `all` 或国家码 | 默认 `all` |
| `life_from` / `life_to` | 0～90 | 生命周期列 D0–Dn；默认 0～7 |
| `currency` | 展示用 | 矩阵累计仍为内部 USD；前端按 `GET /api/fx` 的 R 折到展示货币 |
| `custom_event` | string | **`report_type=custom` 时必填**；事件 `name`。也接受 `event_name` / `custom_name` |
| `custom_mode` | `first_time` \| `first_users` \| `count` \| `total_count` | 默认 `first_time`；也接受 `mode`。见 §5.3；`any` 已移除 → 400 |
| `ad_types` | 逗号分隔规范值 | 默认空 = **全部**。如 `rewarded,interstitial`（控制台「仅插屏和激励」）。`report_type=revenue` 或 **`ecpm`** 时生效；`unknown` 与未列类型排除 |
| `app_version` | string | 默认空/`all` = **不限**。精确匹配事件上的 `app_version`（所有报表类型均适用） |

### 5.2 成功响应形状（稳定字段）

```json
{
  "source": "live",
  "project": { "id": "g_puzzle_101", "name": "方块迷城" },
  "reportType": "retention",
  "cohortAxis": "date",
  "granularity": "day",
  "dayBoundary": "calendar",
  "region": "all",
  "from": "2026-09-13",
  "to": "2026-10-12",
  "lifeFrom": 0,
  "lifeTo": 7,
  "days": [0, 1, 2, 3, 4, 5, 6, 7],
  "rows": [
    {
      "key": "2026-10-05",
      "label": "2026-10-05",
      "users": 120,
      "retention": { "0": 1.0, "1": 0.42, "2": null },
      "revenueCumUsd": { "0": 12.5, "1": 28.0, "2": null },
      "mature": { "0": true, "1": true, "2": false },
      "maturedDays": 1
    }
  ],
  "timezone": "UTC",
  "meta": {
    "userCount": 120,
    "generatedAt": "2026-10-12T08:00:00.000Z",
    "timezone": "UTC",
    "timezoneNote": "calendar day boundary uses UTC dates"
  }
}
```

| 字段 | 兼容说明 |
|------|----------|
| `source` | `live` = 真实入库；控制台「示例数据」为前端 mock，不经此 API |
| `timezone` / `meta.timezone` | 本次查询选用的时区（`UTC` 或 `Asia/Shanghai`）；与请求一致 |
| `meta.timezoneNote` | 英文短说明：calendar 日界所用时区；`rolling24h` 时注明 dayIndex 不受时区影响 |
| `rows[].retention[d]` | 留存率 0～1；未成熟为 `null` |
| `rows[].revenueCumUsd[d]` | 至 Dn 的累计广告收益（USD）；未成熟为 `null` |
| `rows[].mature[d]` | 该列是否已成熟可展示 |

错误：`400 project_id_required` / `404 project_not_found`。

---


### 5.3 自定义事件 Cohort（`report_type=custom`）

| `custom_mode` | UI 文案 | 单元格含义（成熟列） | `meta.metricKind` | 格式建议 |
|---------------|---------|----------------------|-------------------|----------|
| **`first_time`**（默认） | 首次达成转化率 | 至 Dn **至少有过一次**该事件的用户数 / n（按该用户该 `name` 的**首次** `ts` 算 dayIndex） | `rate` | 0–1 比率（前端 %） |
| **`first_users`**（新） | 首次达成人数 | 至 Dn 已首次达成的**用户数**（绝对人数） | `count` | 整数 |
| **`count`** | 人均次数 | 至 Dn **总发生次数** / n | `count` | 小数（人均） |
| **`total_count`**（新） | 累计总次数 | 至 Dn **总发生次数**（绝对次数，**不**除以 n） | `count` | 整数 |

其中 **n** = 该行 `users` / 安装人数；「至 Dn」= occurrence 的 `dayIndex ≤ Dn`；未成熟列 `customMetric[d]=null`；`n===0` 时成熟列给 `0`。

**已删除 `any`（「当日发生」）**：请求仍带 `custom_mode=any`（或其它非法值）时返回 **400** `invalid_custom_mode`，message 提示改用四档之一。控制台加载旧预设含 `any` 时降级为 `first_time`。

响应 additive 字段（`v1` 内旧客户端可忽略）：

| 字段 | 说明 |
|------|------|
| `customEvent` | 本次查询的事件名 |
| `customMode` | `first_time` / `first_users` / `count` / `total_count` |
| `rows[].customMetric[d]` | 上述口径下的数值；未成熟为 `null` |
| `meta.metricKind` | `rate`（仅 `first_time`）或 `count`（其余三档） |
| `meta.customModeNote` | 英文短说明 |

`rows[].retention[d]` **仅**在 `first_time` 时镜像比率，便于旧前端；`count` / `first_users` / `total_count` 下 `retention[d]=null`，请读 `customMetric`。

列出某项目已出现过的自定义事件名：

```http
GET /api/custom-events?project_id=g_puzzle_101
```

```json
{
  "project_id": "g_puzzle_101",
  "names": [{ "name": "tutorial_done", "count": 12 }, { "name": "level_clear", "count": 40 }]
}
```

错误：`400 custom_event_required`（custom 报表未传事件名）；`400 invalid_custom_mode`（非法或已移除的 `any`）。

### 5.4 地区列表 `GET /api/regions`

控制台「受众 · 地区」在真实入库模式下的选项来源（示例模式仍用本地示例子集）：

```http
GET /api/regions?project_id=g_puzzle_101
```

```json
{
  "project_id": "g_puzzle_101",
  "regions": [
    { "code": "US", "name": "美国", "count": 120 },
    { "code": "XX", "name": "XX", "count": 8 }
  ]
}
```

空 / 缺失的 `users.region` 归一为 `XX`，与 `cohort_axis=region` 报表一致。`count` 为该项目下安装用户数。

### 5.4 广告类型筛选（`report_type=revenue` / `ecpm`）

- 查询参数 `ad_types`：逗号分隔 `rewarded` / `interstitial` / `banner`。
- 空或缺省 = 全部类型（含 `unknown`）。
- 控制台选项：**全部** / **仅插屏和激励**（`ad_types=rewarded,interstitial`）。
- 响应中 `adTypes` 与 `meta.adTypes` 回显实际生效的过滤列表（空数组 = 全部）。
- 对 **eCPM** 与收益报表同样生效（全屏 = `rewarded,interstitial`）。

### 5.5 eCPM Cohort（`report_type=ecpm`）

口径：`eCPM = (累计 revenue_usd ÷ 累计展示数) × 1000`；展示数 = 筛选后 `ad_revenue` **事件条数**（一条收益 = 一次展示）。

- 矩阵单元格为**累计至 Dn**；附 `impressionsCum`、`revenueCumUsd`；无展示或未成熟 → `null`。
- **全部 cohort 筛选适用**：`date_*` / `region` / `life_*` / `day_boundary` / `cohort_axis`（含 **`ad_type`**）/ `granularity` / `ad_types` / **`app_version`**。
- 响应 `meta.metricKind = "ecpm"`，`meta.ecpmNote` 说明公式。

### 5.6 广告类型同类群轴（`cohort_axis=ad_type`）

第三轴：**行 = 广告类型**，**列 = 生命周期 Dn**（与日期/地区轴并列，additive）。

| 项 | 说明 |
|----|------|
| 行 `key` / `label` | 规范类型：`rewarded` / `interstitial` / `banner`；无筛选且存在数据时可选含 `unknown` |
| 列 | 仍为 `life_from`…`life_to` 的 Dn |
| 与 `ad_types` 筛选 | **并存**：轴决定行拆分；`ad_types` 仍收窄参与汇总的类型（及出现哪些行） |
| 适用报表 | **优先 `ecpm` / `revenue`**；留存/自定义可选（按「曾产生该类型 `ad_revenue`」的用户子集） |
| 粒度 | 与地区轴相同：`granularity` 不生效 |
| 响应 | `cohortAxis":"ad_type"`；`meta.cohortAxisNote` 短说明 |

控制台：同类群轴增加「**广告类型同类群**」；选 eCPM/收益时最有用；日期粒度在类型轴下禁用。

草案：[`CLIENT_API_ad_type_axis_draft.md`](CLIENT_API_ad_type_axis_draft.md)。

### 5.7 `GET /api/app-versions`

```http
GET /api/app-versions?project_id=<id>
```

```json
{ "ok": true, "versions": ["1.2.3", "1.2.2", "1.1.0"], "count": 3 }
```

按该项目事件与用户档案中出现过的版本去重，字典序/语义降序。控制台「App 版本」下拉据此填充。


## 6. 项目与健康检查

### 6.1 `GET /api/projects`

返回项目列表（无完整 Key）。见 §2.3。

### 6.2 `POST /api/projects`

登记项目。见 §2.2。

### 6.3 `GET /api/health`

公开探活（**不**返回库路径、计数或汇率）：

```json
{ "ok": true }
```

运维详情（需控制台 Bearer）：`GET /api/health/detail` → `{ ok, db, projects, users, events, presets, fx, console_auth }`。

### 6.4 筛选预设 `GET|POST /api/presets`（及 `/api/v1/presets`）

全局共用的筛选条件快照（**不是**静态报表）。**需控制台 Bearer**；与控制台登录一致。

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/presets` | `{ presets: [...] }` |
| POST | `/api/presets` | 新建；body 含 `name` + 筛选字段 |
| GET/PUT/DELETE | `/api/presets/:id` | 读 / 更新 / 删 |

单条形状：`{ id, name, project_id, filters, created_at, updated_at }`。  
`filters` 至少可含：`report_type`、`cohort_axis`、`granularity`、`day_boundary`、`timezone`、`date_from`、`date_to`、`date_preset`、`region`、`life_from`、`life_to`、`custom_event`、`custom_mode`、`currency`；可选 `ad_types` / `app_version` / `ecpm`（控制台无对应控件时忽略）。缺省 `timezone` 的旧预设按 `UTC`。  
产品草案：[`CONSOLE_UI_presets_heatmap_draft.md`](./CONSOLE_UI_presets_heatmap_draft.md)。


---

## 7. 日界、日期区间与生命周期

### 7.1 `day_boundary`

| 值 | 含义 |
|----|------|
| `calendar`（默认） | 按所选 **`timezone` 日历日** 的 0 点分桶：`dayIndex = tz_date(event) - tz_date(install)` |
| `rolling24h` | 按安装时刻起每 24 小时：`floor((eventTs - installTs) / 86400000)`（**不受** `timezone` 影响） |

控制台文案「自然日 / 24 小时制」对应上表。

### 7.1.1 `timezone`（报表日历时区）

入库 `ts` / `installTs` **仍为 UTC 毫秒**；本参数只改变报表对日历日的解释，**不 re-ingest**。

| 值 | 含义 | 对齐 |
|----|------|------|
| `UTC`（默认） | 日历日按 UTC 0 点 | Unity Ads / MAX **API** 日界 |
| `Asia/Shanghai` | 日历日按东八区 0 点（UTC+8） | 中国本地自然日 |

别名：`tz=UTC` / `tz=UTC+8`（`UTC+8` ≡ `Asia/Shanghai`）。非法值 → `400`。

受影响（仅 `day_boundary=calendar`）：

1. **`date_from` / `date_to`**：安装日闭区间按该时区日历日；缺省「近 30 日」亦按该时区「今天」起算。
2. **日历日分桶 / dayIndex**。
3. **日期轴 `cohort_axis=date` 的 bucket / label**（`day`/`week`/`month`/`year`；week 仍为 ISO 周一，但周一边界落在该时区）。
4. **成熟度 `mature` / `maturedDays`**（用该时区「今天」相对安装日）。

`rolling24h`：dayIndex / 成熟逻辑与改前一致；响应仍回显 `timezone` / `meta.timezone`，`timezoneNote` 注明 dayIndex 不受时区影响。

控制台侧栏：「对齐广告平台(UTC)」/「中国(UTC+8)」；CSV 元数据含 `# timezone`。

### 7.2 日期区间 vs 生命周期

- **`date_from`～`date_to`**：筛选 **安装日**落在该区间的用户（同类群人口；按所选 `timezone` 解释日历日）。
- **`life_from`～`life_to`（D0～Dn）**：只决定矩阵 **展示哪些生命周期列**，并裁剪观察窗。

示例：日期区间 = 过去 90 日，生命周期 = D0～D7 → 人群是 90 日内安装的用户，但每行只算/展示到 D7（约 8 列）。未满成熟天数的单元格为 `null`（未成熟），不是 0。

### 7.3 粒度 `granularity`（日期同类群）

| 值 | 桶 |
|----|----|
| `day` | 所选时区日 `YYYY-MM-DD` |
| `week` | ISO 周（周一为起点；周一边界在所选时区） |
| `month` | `YYYY-MM`（所选时区） |
| `year` | `YYYY`（所选时区） |

---

## 8. 最小可运行示例

### 8.1 curl

```bash
BASE=https://daynlab.cc
KEY=ak_demo_puzzle_101_v1

# 单条 install（强烈建议带齐 region / ad_id / app_version / internal_id / ts）
curl -sS -X POST "$BASE/api/v1/events" \
  -H 'Content-Type: application/json' \
  -H "X-Api-Key: $KEY" \
  -d '{"event":"install","user_id":"u_doc_1","internal_id":"u_doc_1","ad_id":"00000000-0000-0000-0000-000000000001","ts":"2026-10-05T08:00:00Z","region":"JP","app_version":"1.2.3"}'

# 批量（数组）
curl -sS -X POST "$BASE/api/v1/events" \
  -H 'Content-Type: application/json' \
  -H "X-Api-Key: $KEY" \
  -d '[
    {"event":"app_open","user_id":"u_doc_1","ts":"2026-10-06T09:00:00Z","region":"JP"},
    {"event":"ad_revenue","user_id":"u_doc_1","ts":"2026-10-05T10:00:00Z","revenue":0.05,"currency":"USD","ad_type":"rewarded","region":"JP"}
  ]'

# 自定义事件（单次或可重复都用同一上报；口径在报表选）
curl -sS -X POST "$BASE/api/v1/events" \
  -H 'Content-Type: application/json' \
  -H "X-Api-Key: $KEY" \
  -d '{"event":"custom","name":"tutorial_done","user_id":"u_doc_1","ts":"2026-10-05T12:00:00Z","region":"JP"}'

# 成功时预期：HTTP 200，body 含 "ok":true 与 accepted
```

Bearer 等价：

```bash
curl -sS -X POST "$BASE/api/v1/events" \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $KEY" \
  -d '{"event":"app_open","user_id":"u_doc_1","ts":1760000000000,"region":"US"}'
```

### 8.2 Unity C#（示意，字段名保持与 API 一致）

```csharp
using UnityEngine;
using UnityEngine.Networking;
using System.Text;
using System.Collections;

public class CohortClient : MonoBehaviour {
  // 生产请改为配置下发；勿写死 demo key
  const string Endpoint = "https://daynlab.cc/api/v1/events";
  const string ApiKey = "ak_demo_puzzle_101_v1";

  public void TrackInstall(string userId, string region) {
    long ts = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    string json =
      "{\"event\":\"install\",\"user_id\":\"" + Escape(userId) +
      "\",\"ts\":" + ts + ",\"region\":\"" + Escape(region) + "\"}";
    StartCoroutine(Post(json));
  }

  public void TrackAppOpen(string userId, string region) {
    long ts = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    string json =
      "{\"event\":\"app_open\",\"user_id\":\"" + Escape(userId) +
      "\",\"ts\":" + ts + ",\"region\":\"" + Escape(region) + "\"}";
    StartCoroutine(Post(json));
  }

  public void TrackAdRevenue(string userId, double revenue, string currency, string placement) {
    long ts = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    string json =
      "{\"event\":\"ad_revenue\",\"user_id\":\"" + Escape(userId) +
      "\",\"ts\":" + ts +
      ",\"revenue\":" + revenue.ToString(System.Globalization.CultureInfo.InvariantCulture) +
      ",\"currency\":\"" + Escape(currency) +
      "\",\"placement\":\"" + Escape(placement) + "\"}";
    StartCoroutine(Post(json));
  }

  IEnumerator Post(string json) {
    using (var req = new UnityWebRequest(Endpoint, "POST")) {
      byte[] body = Encoding.UTF8.GetBytes(json);
      req.uploadHandler = new UploadHandlerRaw(body);
      req.downloadHandler = new DownloadHandlerBuffer();
      req.SetRequestHeader("Content-Type", "application/json");
      req.SetRequestHeader("X-Api-Key", ApiKey);
      yield return req.SendWebRequest();
      long code = req.responseCode;
      string text = req.downloadHandler.text;
      if (code == 200 && text.Contains("\"ok\":true")) {
        Debug.Log("cohort ingest ok: " + text);
      } else {
        Debug.LogWarning("cohort ingest fail: " + code + " " + text);
        // 5xx / 网络错误：由你们的重试队列处理
      }
    }
  }

  static string Escape(string s) {
    if (string.IsNullOrEmpty(s)) return "";
    return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
  }
}
```

上述 Endpoint、Header、JSON 字段名在 `v1` 内保持有效；新增可选字段可忽略。

---

## 9. Changelog 与废弃策略

### 9.1 Changelog

| 日期 | 版本 | 说明 |
|------|------|------|
| 2026-10-15 | **v1.14.1** | Additive/修正：`game_id` 首次写入也会把回传模板中的 `{game_id}` 展开为字面量（先前仅替换「旧字面量→新字面量」，空 prev 时看起来「没更新」）；GET 时若已有 game_id 仍含占位符会持久化展开；保存后表单 GET 回填 |
| 2026-10-15 | **v1.14** | Additive：归因 L1「测试设备」可配 GAID（小写）；测试 GAID 匹配安装事件或显式 `fire-install` 时总是新安装回传（绕过重装窗；幂等按测试决策可重复）；`game_id` 写入/更新后刷新 Click/Impression 展示并替换模板字面量 game id；详见 `ATTRIBUTION_UNITY_S2S_MVP.md` |
| 2026-10-15 | **v1.13** | Additive/修正：安装回传模板按 Unity 官方 GP/Android 校对（`advertisingTrackingId`/`gamerId`/`attributed`/`installTimeEpoch`；默认不带 rawAndroidId）；Secondary 默认硬编码 `platform=android`，`{store_id}`/`{tracking_enabled}` 等仍运行时替换；空/不完整 install 与旧 `platform={platform}` secondary 读取时自动升级；详见 `ATTRIBUTION_UNITY_S2S_MVP.md` |
| 2026-10-15 | **v1.12** | Additive/修正：归因 L1 展示/点击仅为结果统计（配置只进父链接）；入库令牌新建自动生成且只读（轮换需确认）；启用归因为确认按钮+徽章（默认关）；回传默认模板对齐 Unity 官方 secondary 字段（`gamer_id`/`ifa`/`aid`/`tracking_enabled`/`campaign_id`/`game_id`/`platform`/`store_id`/`install_ts`/`was_conversion_attributed`；广告收益 `event=ad_revenue`+`revenue_usd`；IAP `event=purchase`）；`installTimeEpoch` 用秒；旧错误 secondary 参数名自动升级；详见 `ATTRIBUTION_UNITY_S2S_MVP.md` |
| 2026-10-15 | **v1.11** | Additive：归因**多链接**（`attribution_links`）；L1 在用/归档分栏 + 每链接展示/点击/转化概括；归档不失效；ingest/决策/回传带 `link_id`；旧 `attribution_config` 迁移为默认链接保 token；详见 `ATTRIBUTION_UNITY_S2S_MVP.md` |
| 2026-10-15 | **v1.10.3** | UI：归因「一级管理入口（列表）」→「二级单链接配置」（链接/回传/门槛/审计；保存与未保存提示仅二级）；契约字段不变 |
| 2026-10-15 | **v1.10.2** | UI：归因侧栏分 Tab（链接 / 回传 / 门槛 / 审计）；安装门槛对标可选 custom 事件（搜索 `/api/custom-events`，留空默认 `win`）；契约字段不变 |
| 2026-10-15 | **v1.10.1** | UI/默认：控制台顶栏「归因链接」模块（与同类群并列）合并入库链+回传模板；新建配置预填 Unity 风格 Install/Revenue 模板；空模板 GET 时 backfill；契约不变 |
| 2026-10-15 | **v1.10** | Additive：Unity S2S 归因 MVP——公开 ingest click/impression（路径令牌）；控制台 config/links/touches/postbacks/token/rotate；默认 dry_run + 可配 winN/收益门槛/重装窗；幂等 postback 审计；详见 `ATTRIBUTION_UNITY_S2S_MVP.md` |
| 2026-10-15 | **v1.9.5** | 文档：Unity S2S 自研归因 MVP 草案 `docs/ATTRIBUTION_UNITY_S2S_MVP.md`（GP+Unity；控制台自动生成 click+展示链；回传 URL 模板+门槛可配；计费幂等/审计）；**未实现**；不改服务端契约 |
| 2026-10-15 | **v1.9.4** | 文档：仓库 `mcp/dayn/` 薄 stdio MCP（前端 agent 探活/打点/报表/读 CLIENT_API）；在线 https://daynlab.cc/mcp/dayn/README.md；不改服务端契约 |
| 2026-10-15 | **v1.9.3** | 文档/运维：生产域名 **https://daynlab.cc**（Let’s Encrypt）；Base URL 改 HTTPS；`http://178.79.148.14` 作过渡备用；不改 API 契约 |
| 2026-10-15 | **v1.9.2** | 文档：原生 SDK v0 **可选**——HTTP/curl 按本文直连为一等接入；有自建网络层可跳过 SDK；待 SDK 足够自动化（离线队列/重试等）后再建议接入；不改服务端 |
| 2026-10-15 | **v1.9.1** | 文档：原生 **Android / iOS SDK v0** 落地 `sdk/android/`、`sdk/ios/`（非 Unity）；§9.3 改为 v0 指针 + 用法；不改服务端硬校验 |
| 2026-10-15 | **v1.9** | Additive：自定义事件四档 `custom_mode=first_time\|first_users\|count\|total_count`（人均次数 / 首次达成人数 / 首次达成转化率 / 累计总次数）；**删除** `any`（当日发生）→ 400 `invalid_custom_mode`；控制台四按钮 + 旧预设 `any`→`first_time`；CSV/表头格式按 mode |
| 2026-10-15 | **v1.8** | Additive：`GET /api/report` 支持 `timezone=UTC\|Asia/Shanghai`（别名 `tz=UTC\|UTC+8`）；默认 UTC；非法 400；`meta.timezone` + `timezoneNote`；仅 `calendar` 日界/安装日/日期轴/成熟度受影响；`rolling24h` 不变；控制台时区切换 + CSV `# timezone`；预设可带 `timezone` |
| 2026-10-15 | **v1.7.1** | 文档：`install` 强烈建议一次带齐 `region` / `ad_id` / `app_version` / `internal_id` / `ts`；补充最小 JSON 示例与「缺 install、靠 app_open 建档 → 档案空 / 地区 XX」后果说明；不改变服务端硬校验 |
| 2026-10-15 | **v1.7** | Additive + 安全：项目多标签 `tags`；`GET/POST/PATCH /api/projects`（`tag`/`exclude_tag`）；控制台默认隐藏 `test`；控制台接口需 `CONSOLE_TOKEN` Bearer；公开 health 脱敏；`/api/projects/keys` 默认关；草案 `CLIENT_API_project_tags_draft.md` / `CONSOLE_SECURITY.md` |
| 2026-10-15 | **v1.6** | Additive：`cohort_axis=ad_type`（行=广告类型，列=Dn；与 `ad_types` 筛选并存；优先 eCPM/收益）；控制台「广告类型同类群」；草案 `CLIENT_API_ad_type_axis_draft.md` |
| 2026-10-14 | **v1.5** | Additive：筛选预设 CRUD `/api/presets`（及 v1 别名）；控制台保存/应用快照 + 刷新 toast；矩阵 Dn 连续色阶（全表归一）；草案 `CONSOLE_UI_presets_heatmap_draft.md` |
| 2026-10-14 | **v1.4** | Additive：`report_type=ecpm`（累计至 Dn 的 USD eCPM + `impressionsCum`/`revenueCumUsd`）；通用字段 `app_version`；`GET /api/app-versions`；eCPM/收益支持 `ad_types`+`app_version`；控制台 eCPM 报表与版本下拉 |
| 2026-10-14 | **v1.3** | Additive：`ad_revenue` 支持 `ad_type`（`rewarded`/`interstitial`/`banner`，`placement` 兼容别名归一；未知→`unknown`）；`GET /api/report` 支持 `ad_types`；控制台「全部 / 仅插屏和激励」 |
| 2026-10-14 | **v1.2.4** | 文档：用户标识尽量双报 `ad_id`+`internal_id`；`user_id`≈internal；`fingerprint_id` 仅确认重装有效才加 |
| 2026-10-14 | **v1.2.3** | 文档修订：`user_id` — 内部 ID 不靠登录；指纹仅在「删重装仍可认老用户」时启用，否则不引入 |
| 2026-10-14 | **v1.2.2** | Additive 文档：`user_id` 选型规范 — 优先 GAID，其次内部体系 ID，最后指纹 ID（§3.2.1） |
| 2026-10-14 | **v1.2** | Additive：`ad_revenue` 明确原币种 USD/CNY；默认 R=`6.72`（废 7.2）；新增 `GET /api/v1/fx`（及 `/api/fx`）；控制台可切换展示货币、显示 R/来源、人工覆盖与下载指定 R |
| 2026-10-14 | **v1.2.1** | Additive：`GET /api/v1/fx` 增加 `provider`；实时源优先中国银行中行折算价（÷100），其次 Frankfurter、open.er-api |
| 2026-10-14 | **v1.1.1** | Additive：`GET /api/regions`（distinct install region，空→`XX`）；控制台真实模式地区下拉改读该接口；示例模式自定义事件生成独立 `customMetric`（不再用留存冒充）；地区轴时禁用日期粒度并提示仅日期轴生效 |
| 2026-10-14 | **v1.1** | Additive：事件类型 `custom`/`custom_event`（字段 `name`、可选 `value`）；`GET /api/report` 支持 `report_type=custom` + `custom_mode=first_time|count|any`；`GET /api/custom-events`；控制台可选事件名与口径 |
| 2026-10-12 | **v1.0** | 初版契约：`POST /api/v1/events`；项目登记；`GET /api/report`；`calendar`/`rolling24h`；demo keys；本文件作为 canonical |

### 9.2 Deprecation Policy

1. **不静默破坏**：不在无公告的情况下改 `v1` 字段名/语义。
2. **废弃公告**：在本文件与仓库 README 标注 `Deprecated`，给出替代路径与最早关闭日（≥ 公告后 **90 天**）。
3. **双轨**：新主版本上线后，旧版并行至少 90 天。
4. **Additive 优先**：新能力尽量新可选字段 / 新可选事件别名 / 新 query，而不是改旧字段。
5. **客户端义务**：忽略未知 JSON 字段；不要依赖已关闭的内部路径（如 `/api/projects/keys`）；不要把 `CONSOLE_TOKEN` 打进游戏包。

### 9.3 联系与文档入口

- 规范正文：仓库 `docs/CLIENT_API.md`（本文件）
- 在线：https://daynlab.cc/docs/
- 控制台：https://daynlab.cc/
- 备用 IP：http://178.79.148.14/

### 9.3.1 原生 SDK v0（Android / iOS，非 Unity）— **可选**

> **SDK 可选，HTTP 直连一等公民。**  
> v0 是薄封装（字段模板 + `POST /api/v1/events`）。按本文用 HTTP / curl / 自建网络层接入，与接 SDK **同等有效**。  
> 团队已有统一网络层时，**可跳过 SDK**，只跟进本 CLIENT_API。  
> **建议等到 SDK 足够自动化再接**（例如离线队列 / 重试、更少手写接线）；在那之前 SDK 仅作参考实现，**非必接**。

仓库提供的参考实现：

| 平台 | 路径 | 说明 |
|------|------|------|
| Android (Kotlin) | [`sdk/android/`](../sdk/android/) | Library module；`IaaCohortSdk` + `install` / `appOpen` / `adRevenue` / `custom` |
| iOS (Swift) | [`sdk/ios/`](../sdk/ios/) | Swift Package；`IaaCohortSDK` 同上 |
| Smoke | [`sdk/smoke/smoke_install.sh`](../sdk/smoke/smoke_install.sh) | demo key 对现网打一条 `install` |

无论是否用 SDK，仍须遵守：

1. 鉴权只用项目 `X-Api-Key`；**不要**把 `CONSOLE_TOKEN` 打进客户端 / SDK / 游戏包。
2. 主路径：`POST {baseUrl}/api/v1/events`；单请求批量 ≤500。
3. `ts` 报 **UTC**（毫秒或 ISO8601Z）；日历时区只在报表侧。
4. `install` 强烈建议一次带齐：`user_id` / `internal_id` / `ad_id` / `ts` / `region` / `app_version`。

SDK 细节见各目录 README；不改变服务端契约。

---


### 9.3.2 DayN MCP（前端 coding agent，测试用）

仓库 [`mcp/dayn/`](../mcp/dayn/)：stdio MCP，包装本 Base URL 的 health / events / fx / report / 枚举 / CLIENT_API 拉取。  
在线：https://daynlab.cc/mcp/dayn/README.md  

供 Cursor 等 **开发者 agent 联调**，不是游戏客户端；密钥用 `DAYN_API_KEY` / `DAYN_CONSOLE_TOKEN` 环境变量。演示 Key 见 §2.2。

---



### 9.3.3 Unity S2S 自研归因 MVP（已实现）

边界与默认值见 [`ATTRIBUTION_UNITY_S2S_MVP.md`](ATTRIBUTION_UNITY_S2S_MVP.md)（在线 https://daynlab.cc/docs/ATTRIBUTION_UNITY_S2S_MVP.md）。  

公开入库（路径令牌，无 CONSOLE_TOKEN）：

- `GET|POST /api/v1/attribution/ingest/:project_id/:ingest_token/click`
- `GET|POST /api/v1/attribution/ingest/:project_id/:ingest_token/impression`

控制台（Bearer）：`GET /api/attribution/links?archived=`、`POST /api/attribution/links`、`GET|PUT /api/attribution/link`、`…/link/archive|unarchive`、`…/link/token/rotate`、`touches|postbacks`（可带 `link_id`）；兼容旧 `config` / `token/rotate`。  

默认 `dry_run=true`、`postback_enabled=false`、归因启用关。**多链接**：每链接自有**系统生成只读**令牌与 Click/Impression URL；L1 在用/归档分栏 + 展示/点击/转化概括（统计不可点进；转化=本地 attributed 决策）；启用归因为确认+保存；**归档不失效**。回传模板对齐 Unity 官方 install + secondary 字段（见 ATTRIBUTION 文档）。旧 `attribution_config` 迁移为默认链接并保留 token。匹配挂在 `POST /api/v1/events` 之后；**不改变**既有事件/报表字段契约。仍**不是**完整 MMP。

---

*维护说明：改 API 行为时必须同步更新本文件，并在 §9.1 追加一行；仅前端文案变更不必升 API 版本。*
