← API | 列表 | API接口Auth模块
提示信息
# 同伴 App — 接口设计文档

> PHP 8.4 · Slim 4 · PostgreSQL · zircote/swagger-php v4
> 仅含 **App 系统模块** 与 **认证模块**,公共传参、签名规则、错误码见《后端开发规范》

---

## 5.1 App 系统模块 `/app`

### `POST /app/start` — 启动初始化(白名单)

#### 调用场景

App 进程冷启动时调用,是整个会话的第一个请求。主要覆盖以下几个时机:

- 用户首次安装后打开 App
- 用户手动杀进程后重新打开
- 系统内存不足将 App 杀掉后重新打开(iOS 的 `applicationDidBecomeActive` + 非 resume 场景 / Android 的 `Application.onCreate`)

**[业务逻辑]** 若已登录(Header 携带 Bearer Token),服务端会自动触发**每日自动签到**逻, 记录签到统计与流水。

已登录时需携带 Bearer Token,服务端据此填充 `user_status`;未登录时 Token 留空,`user_status` 全部返回 `false`。

#### 调用频率

**每次冷启动调用 1 次**,不重复调用。客户端需本地缓存响应结果(TTL 建议 1 小时),冷启动时先读缓存渲染,后台异步刷新更新本地缓存。App 切换到后台再回到前台属于热启动(resume),**不触发**此接口。

App 冷启动时调用,合并了配置下发 + 版本检查 + 用户状态,减少首屏请求次数。未登录时仅返回全局配置,已登录(携带 Bearer Token)时额外返回用户状态。

#### 请求体

```json
{
  "platform":    "android",
  "version_code": 10234,
  "channel":     "huawei"
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `platform` | string | ✅ | `ios` / `android` |
| `version_code` | int | ✅ | 当前版本号(整型) |
| `channel` | string | — | 发行渠道,影响 `download_url` |

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "review_mode": false,

    "ad": {
      "enable": false,
      "config_version": "v2024060112",
      "app_ids": {},
      "strategies": {
        "splash": [],
        "reward_video": []
      }
    },

    "update": {
      "has_update":        false,
      "force_update":      false,
      "latest_version_code": 10234,
      "latest_version_name": "3.2.0",
      "release_notes":     "",
      "download_url":      "",
      "store_url":         ""
    },

    "privacy": {
      "version": "20240301",
      "url":     "https://example.com/privacy"
    },

    "features": {
      "signal_daily_pull": 20,
      "ad_pull_unlock":     5,
      "signal_max":         5
    },

    "links": {
      "user_agreement": "https://example.com/agreement",
      "help_center":    "https://example.com/help",
      "feedback_h5":    "https://example.com/feedback"
    },

    "user_status": {
      "force_set_password": false,
      "young_mode":        false
    }
  },
  "server_time": 1717200001
}
```

#### 字段说明

| 字段 | 类型 | 说明 |
|---|---|---|
| `review_mode` | bool | 审核模式,开启时隐藏广告、部分功能,避免商店审核被拒 |
| `ad.config_version` | string | 广告配置版本,客户端与本地缓存比较,变化时才刷新广告 SDK |
| `ad.strategies` | object | 各广告位的渠道优先级,`priority` 越小越优先 |
| `update.has_update` | bool | 是否有新版本可用 |
| `update.force_update` | bool | 是否强制升级(当前版本低于最低支持版本) |
| `update.download_url` | string | Android 直链;iOS 留空,用 `store_url` 跳商店 |
| `privacy.version` | string | 最新隐私协议版本号,客户端比对后决定是否弹授权窗 |
| `features` | object | 从 `config/app.php` 透传的业务参数,客户端读取避免硬编码 |
| `links` | object | H5 跳转链接,后台可随时修改免发版 |
| `user_status.force_set_password` | bool | 是否需要强制设置密码(未设密码且注册超 2 天) |
| `user_status.young_mode` | bool | 是否处于未成年人保护模式(已登录才有效) |

#### 开发备注

- 未登录时 `user_status` 所有字段返回 `false`,不报错。
- `update` 中的强制更新判断:`version_code < min_supported_version_code`,该阈值存 `app_versions` 表。
- 客户端本地缓存此接口结果,TTL 建议 1 小时;冷启动先读缓存展示,异步刷新后更新。

---

### `GET /app/config` — 获取核心配置(白名单)

#### 调用场景

不面向 App 客户端,主要用于以下两类场景:

- **运维健康检查**:负载均衡器或监控系统定期 ping,验证服务是否正常响应
- **开发调试**:快速确认当前部署的服务名称和版本号是否符合预期

App 客户端启动配置应调用 `/app/start`,不应直接调用此接口。

#### 调用频率

客户端不主动调用。运维侧按需调用,无频率限制要求。

轻量接口,仅返回应用名称和版本,用于 debug 或健康检查。

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "app_name": "同伴",
    "version":  "3.2.0"
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `app_name` | string | 应用名称,来自 `config('app.name')` |
| `version` | string | 应用版本号,来自 `config('app.version')` |

---

### `GET /app/version` — 版本检查(白名单)

#### 调用场景

已从 `/app/start` 中分离出来作为独立接口,主要用于以下几个时机:

- **启动时**:`/app/start` 已内含版本检查结果,大多数情况下不需要单独调用此接口
- **设置页主动检查更新**:用户手动点击"检查更新"按钮时调用


#### 调用频率

- 启动时:通常由 `/app/start` 覆盖,**不单独调用**
- 用户主动触发:每次点击检查更新调用 1 次


#### 请求参数(Query String)

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `platform` | string | ✅ | `ios` / `android` |
| `version_code` | int | ✅ | 当前版本号 |
| `channel` | string | — | 发行渠道 |

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "has_update":          true,
    "force_update":        false,
    "latest_version_code": 10240,
    "latest_version_name": "3.2.0",
    "release_notes":       "· 优化启动速度\n· 修复若干已知问题",
    "download_url":        "https://example.com/download/android/v3.2.0.apk",
    "store_url":           ""
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `has_update` | bool | 是否有新版本 |
| `force_update` | bool | 是否强制更新 |
| `release_notes` | string | 更新日志,`\n` 换行 |
| `download_url` | string | Android APK 直链 |
| `store_url` | string | iOS App Store 链接 |

---

### `POST /app/heartbeat` — 在线心跳(需登录)

#### 调用场景

用于维持用户在线状态,同时作为轻量的服务端主动推送通道,覆盖以下场景:

- **维持在线**:让其他用户(如聊天对象)能看到"对方在线"状态;Redis `user_online:{uid}` TTL 120s,心跳不续期则自然离线
- **未读数轮询**:顺带下发 `unread.message`、`unread.notification`,客户端无需额外发起查询请求
- **服务端下发指令**:通过 `commands` 字段实现轻量服务端推送,处理强制下线、系统公告弹窗等低频事件,比长轮询轻量、比 WebSocket 省资源
- **客户端校时**:`server_time` 用于修正客户端本地时间偏差,影响签名时间窗口判断

#### 调用频率

- App **在前台活跃时**:每 **30 秒**调用 1 次
- App **切换到后台**:立即停止,不再发送
- App **从后台恢复到前台**:立即补发 1 次,然后重启 30 秒定时器
- 服务端保护:同一 uid 间隔 < 10s 的请求静默忽略(不报错,不更新 Redis),防止异常客户端轰炸

> **注意**:用户未登录时不调用此接口。

> Redis key:`user_online:{uid}`,TTL 120s,自然过期即为离线。

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "server_time": 1717200001,
    "unread": {
      "message":      3,
      "notification": 1
    },
    "commands": []
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `server_time` | int | 服务端当前时间戳,用于客户端校时 |
| `unread.message` | int | 私信未读数 |
| `unread.notification` | int | 互动通知未读数(点赞/评论等) |
| `commands` | array | 服务端下发指令,如强制下线、弹窗公告 |

#### `commands` 结构示例

```json
[
  { "type": "force_logout", "reason": "账号在其他设备登录" },
  {
    "type":    "show_dialog",
    "title":   "系统维护通知",
    "content": "今晚 23:00-01:00 进行系统维护",
    "actions": [{ "label": "知道了", "type": "dismiss" }]
  }
]
```

#### 开发备注

- 心跳频率:前台活跃 30s 一次,切后台停止,回到前台立即补发一次。
- 服务端对同一 uid 做频率保护,< 10s 的请求静默忽略,不报错。

---

## 5.2 认证模块 `/auth`

### `POST /auth/sms/send` — 发送验证码(白名单)

#### 调用场景

任何需要短信验证码的操作前调用,通过 `scene` 区分场景,不同场景 Redis key 相互隔离:

- `login`:登录/注册页点击"获取验证码",最高频场景
- `reset_password`:找回密码流程,用户忘记密码时触发
- `bind_phone`:第三方账号(微信/Apple)绑定手机号时触发,或用户在设置页更换手机号

#### 调用频率

- **用户主动触发**:每次点击"获取验证码"按钮调用 1 次
- **重发**:客户端展示 60 秒倒计时,倒计时归零后用户可再次点击,即最短间隔 60 秒
- **服务端限流**:同手机号同场景 60s 内最多 1 次;同 IP 每小时最多 10 次(防刷)

#### 请求体

```json
{
  "phone": "13800138000",
  "scene": "login"
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `phone` | string | ✅ | 11 位手机号 |
| `scene` | string | ✅ | `login` / `reset_password` / `bind_phone` |

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "expire_seconds":      300,
    "retry_after_seconds": 60
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `expire_seconds` | int | 验证码有效期(秒),客户端用于倒计时 |
| `retry_after_seconds` | int | 重发冷却时间(秒),客户端禁用重发按钮 |

#### 开发备注

- 限流:同手机号 60s 内只允许发 1 次,同 IP 每小时上限 10 次。
- 验证码:6 位数字,有效期 5 分钟,最多失败验证 3 次后当次码作废。
- `scene` 隔离:各场景 Redis key 独立,防止登录码被用于改密场景。

---

### `POST /auth/login/phone` — 手机号登录(白名单)

#### 调用场景

用户在登录页完成手机号输入后提交时调用,覆盖以下几种情况:

- **新用户**:输入手机号 + 验证码,服务端自动注册并登录,`is_new_user=true`,客户端跳新手引导页
- **老用户验证码登录**:手机号 + 验证码,适合不记得密码或快速登录的场景
- **老用户密码登录**:手机号 + 密码,适合已设置密码的用户

#### 调用频率

**用户主动触发,每次登录调用 1 次**。在 Token 有效期内不会再次调用(续期走 `/auth/token/refresh`)。

支持验证码登录和密码登录,手机号不存在时自动注册。

#### 请求体(验证码登录)

```json
{
  "phone": "13800138000",
  "code":  "123456",
  "device_info": {
    "device_id":   "uuid-xxx",
    "platform":    "android",
    "model":       "Pixel 8",
    "os_version":  "14",
    "app_version": "3.2.0",
    "push_token":  "fcm-token-xxx"
  }
}
```

#### 请求体(密码登录)

```json
{
  "phone":    "13800138000",
  "password": "your_password",
  "device_info": { ... }
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `phone` | string | ✅ | 手机号 |
| `code` | string | — | 短信验证码,与 `password` 二选一 |
| `password` | string | — | 登录密码,与 `code` 二选一 |
| `device_info` | object | ✅ | 设备信息,用于推送绑定和风控 |
| `device_info.device_id` | string | ✅ | 客户端生成的唯一设备标识 |
| `device_info.push_token` | string | — | FCM / APNs 推送 token |

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "is_new_user":         false,
    "access_token":        "eyJhbGci...",
    "refresh_token":       "dGhpcyBp...",
    "access_expires_in":   7200,
    "refresh_expires_in":  2592000,
    "im_token":            "openim-token-xxx",
    "user": {
      "uid":          10086,
      "nickname":     "用户10086",
      "avatar":       "",
      "gender":       0,
      "is_verified":  false,
      "is_vip":       false
    }
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `is_new_user` | bool | 是否新注册,客户端用于决定是否跳新手引导 |
| `access_token` | string | 短期访问令牌,有效期来自 `config('jwt.expire')`,默认 2h |
| `refresh_token` | string | 长期刷新令牌,有效期来自 `config('jwt.refresh_expire')`,默认 30d |
| `access_expires_in` | int | access_token 剩余有效秒数 |
| `refresh_expires_in` | int | refresh_token 剩余有效秒数 |
| `im_token` | string | OpenIM 登录 token,客户端初始化 IM SDK 使用 |
| `user.uid` | int | 用户 ID |
| `user.is_verified` | bool | 是否完成实名认证 |
| `user.is_vip` | bool | 是否 VIP |

#### 开发备注

- Token 存储:客户端将 refresh_token 存入 Keychain(iOS)/ EncryptedSharedPreferences(Android)。
- `device_info` 写入 `user_devices` 表,记录登录日志到 `user_login_logs`。



### `POST /auth/token/refresh` — 刷新 Token(白名单)

#### 调用场景

客户端**静默调用**,用户无感知,覆盖以下触发时机:

- **主动续期**:App 冷启动时检测本地 `access_token` 剩余时间 < 30 分钟,在后台静默刷新
- **请求失败续期**:任意业务接口返回 `401`(Token 过期),客户端拦截器自动调用此接口换取新 Token,然后重放原请求
- **定时检查**:App 在前台长时间运行(如用户一直刷内容),建议在 `access_token` 到期前 10 分钟静默刷新,避免用户操作时突然 401

#### 调用频率

- 正常使用:`access_token` 默认有效期 2 小时,理论上每 **2 小时**触发 1 次静默刷新
- 服务端保护:同一 `refresh_token` 在极短时间内(< 3s)的重复请求应返回同一结果(幂等处理),防止客户端并发刷新时多次消耗 Rotation 配额

#### 请求体

```json
{
  "refresh_token": "dGhpcyBp..."
}
```

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "access_token":       "eyJhbGci...",
    "access_expires_in":  7200,
    "refresh_token":      "bmV3cmVm...",
    "refresh_expires_in": 2592000,
    "im_token":           "eyJhbGci..."
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `access_token` | string | 新的访问令牌 |
| `refresh_token` | string | 新的刷新令牌(Rotation 机制,旧 token 立即失效) |
| `access_expires_in` | int | 新 access_token 有效秒数 |
| `refresh_expires_in` | int | 新 refresh_token 有效秒数 |
| `im_token` | string | **[可选]** 新的 OpenIM Token(如需同步刷新) |

#### 开发备注

- Refresh Token Rotation:每次刷新同时下发新 refresh_token,旧 token 加入 `token_blacklist:{jti}` 黑名单,可检测重放攻击。
- 客户端并发刷新保护:客户端需加锁,多个并发请求只发一次 refresh,其余等待复用新 token。

---

### `POST /auth/im/token` — 刷新 OpenIM Token(需登录)

#### 调用场景

当客户端 IM SDK 报告 Token 过期(ErrorCode: 10001)或开发者希望主动更新 IM 身份凭证时调用。

#### 调用频率

- 正常情况下,OpenIM Token 有效期较长(24h+),建议在接入层捕获 SDK 异常后按需调用。

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "im_token": "eyJhbGci..."
  },
  "server_time": 1717200001
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `im_token` | string | 新的 OpenIM Token |

---

### `POST /auth/logout` — 退出登录(需登录)

#### 调用场景

用户主动触发,覆盖以下几种情况:

- **设置页退出**:用户在"我的 → 设置 → 退出登录"点击确认后调用
- **切换账号**:用户要切换另一个账号时,先退出当前账号
- **被服务端强制下线**:心跳返回 `commands` 包含 `force_logout` 时,客户端收到后应立即调用此接口清理 Token,然后跳转到登录页

#### 调用频率

**用户主动触发,每次退出调用 1 次**,极低频。无需限流保护,但服务端应做幂等处理(Token 已在黑名单中时不报错,直接返回成功)。

#### 请求体

```json
{
  "device_id": "uuid-xxx"
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | — | 当前设备 ID,用于精确解绑推送 token |

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {},
  "server_time": 1717200001
}
```

#### 开发备注

- 服务端将当前 access_token 的 `jti` 写入 `token_blacklist:{jti}`,TTL = token 剩余有效期。
- 同时将当前 refresh_token 加入黑名单。
- 解绑该设备 `push_token`,避免退出后继续收推送。
- 客户端无论服务端是否成功,本地都必须清除所有 token 和用户缓存。

---

## 5.2 用户模块 `/user`

### `GET /user/profile/me` — 获取个人资料(需登录)

#### 调用场景

进入 "我的" 页面获取基本信息,或进入 "资料编辑" 页面获取当前字段值。

#### 响应示例

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "user": {
      "uid": 2,
      "nickname": "测试用户",
      "avatar": "https://...",
      "gender": 1,
      "age": 25,
      "bio": "这是简介",
      "city": "上海",
      "residence": {
        "province": "上海",
        "city": "上海",
        "district": "浦东新区"
      },
      "birth_date": "1999-01-01",
      "orientation": 1,
      "mbti": "INTJ",
      "is_verified": false,
      "is_vip": true
    }
  }
}
```

---

### `POST /user/profile/update` — 更新个人资料(需登录)

#### 调用场景

- **资料编辑页**:修改单项或多项资料
- **补全引导页**:注册后完善性别、生日等必填项

#### 业务逻辑

**支持部分更新(Partial Update)**。请求体中仅传入需要修改的字段即可,未传入的字段将保持不变。

#### 请求体

```json
{
  "nickname": "新昵称",
  "bio": "新的人生格言",
  "gender": 2,
  "birth_date": "2000-05-20",
  "mbti": "ENFP",
  "residence": {
    "province": "北京",
    "city": "北京"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `nickname` | string | 昵称 |
| `avatar` | string | 头像 URL |
| `gender` | int | 性别 (1-男, 2-女) |
| `birth_date` | string | 生日 (YYYY-MM-DD) |
| `bio` | string | 个人简介 |
| `mbti` | string | MBTI 人格类型 |
| `residence` | object | 常住地 JSON 对象 |
| `orientation` | int | 性取向 |
| `attribute` | int | 属性/角色 |

#### 响应示例

成功后返回最新的完整用户信息(同 `GET /user/profile/me` 的 data 结构)。

---

## 启动时序参考

```
App 冷启动
  │
  ├─ 并行 ─────┬── POST /app/start          (配置 + 版本 + 用户状态,一次拿齐)
  │             └── 本地读取缓存 Token 判断登录态
  │
  ├─ 已登录且 access_token 即将过期 ──── POST /auth/token/refresh
  │
  └─ 首屏渲染完成后 ──── 开始定时 POST /app/heartbeat(30s/次)
```