---
url: /docs/api-docs/v1/city.md
---
# city —— 选择城市（C 端 / 4 action）

> 统一入口：`POST /api/v1/city`
> body = `{ "action": "<4种之一>", "payload": { ... } }`
> 鉴权：**3 action 需登录**（selectCity / getCurrentCity / getRecentCities），**1 action 公开**（listCities 选城市页用）
> 契约权威：`src/app/city/interface/api/city_actions.py`（DDD interface 层，路由必须在此 — 详踩坑见 `docs/internal-wiki/decisions/0013-city-domain.md`）

## 4 个 Action 一览

| Action | 鉴权 | 触发场景 | 详细 |
|---|---|---|---|
| `listCities` | 无 | 选城市页进入 / 搜索 | [→ §1](#1-listcities) |
| `selectCity` | Bearer | 用户点选 / 提交定位 | [→ §2](#2-selectcity) |
| `getCurrentCity` | Bearer | 首页 / 我的页查当前城市 | [→ §3](#3-getcurrentcity) |
| `getRecentCities` | Bearer | 选城市页 onShow 拉最近 + 热门 | [→ §4](#4-getrecentcities) |

## 公共约定

### 统一信封（V2 全局）

* **成功**：`{ "code": "0", "msg": "ok", "data": { ... } }`
* **业务失败**：HTTP 200 + `{ "code": "40431xxx", "msg": "<原因>" }`
* **入参校验失败**：HTTP 400 + `{ "msg": "<字段路径>: <原因>" }`
* **响应 data 全部 camelCase**（V2 风格：`adcode` / `parentCode` / `isHot` / `updatedAt` / `boundAt`）

### city 对象结构

```json
{
  "adcode": "440100",
  "name": "广州市",
  "parentCode": "440000",
  "level": 2,
  "pinyin": "guangzhou",
  "isHot": true
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `adcode` | string | 6 位行政区划码（110000=北京 / 440100=广州 / 500000=重庆等） |
| `name` | string | 中文名（含「市」） |
| `parentCode` | string | null | 父级 adcode（直辖市 = 自身 adcode） |
| `level` | int | 1 省 / 2 市（含直辖市场次归 2 — 见 D-6） |
| `pinyin` | string | null | 拼音首字母（去「市」） |
| `isHot` | bool | 10 热门城市标记（启动屏首推） |

### HTTP 状态映射

| 层 | HTTP | 说明 |
|---|---|---|
| 参数校验失败 | **400** | payload 格式错（双模入参并存 / level 越界） |
| 业务失败（默认） | **400** | `40431001` / `40431005` |
| 未登录 | **401** | `40431004` |
| 城市不存在 | **404** | `40431002` |
| 用户未绑城市（**新用户常态**） | **404** | `40431003`（前端按 code 跳引导页，**不打错误 toast**） |

### 错误码矩阵（40431xxx）

| code | 语义 | HTTP | 前端处置 |
|---|---|---|---|
| `40431001` | 参数错（含双模入参并存 / unknown action / adcode 非 6 位 / level 越界） | 400 | toast |
| `40431002` | adcode 不存在 | 404 | toast |
| **`40431003`** | **用户未绑城市**（新用户常态） | 404 | **跳引导页 / 选城市页**，不当错误 |
| `40431004` | 未登录 / token 无效 | 401 | 走登录 |
| `40431005` | GPS 转码失败（**v1 GPS 不可用** — seed 无 geo → `SeedNearestCityLocator` 恒返 None） | 400 | **前端 v1 不做「自动定位」按钮**，只做手动选择 |

### 双模入参契约（D-7 · selectCity）

`selectCity` 支持两种入参，**二选一**，双传 → `40431001`：

| 模式 | payload | 适用 |
|---|---|---|
| **手动** | `{ "adcode": "440100" }` | 用户点选 / 搜索结果 |
| **GPS** | `{ "gps": { "lng": 113.2644, "lat": 23.1291 } }` | 自动定位（**v1 不可用** — 必返 40431005） |

> ⚠️ **GPS 路径隐私红线**：后端一次性转码，**坐标不持久化**（不会落 PG，不会写日志）。

### seed 数据规模

* **368 条**：27 省 + 341 地级市
* **不含区县**（D-6 v1 决策 —— 用户选城市粒度是「我在哪个市」）
* 数据源：`src/app/city/seeds/adcode_seed.json`（`scripts/gen_city_seed.py` 生成；modood/administrative-divisions-of-China 派生）
* **10 热门城市**（`isHot=true`）：北京/上海/广州/深圳/佛山/东莞/南京/无锡/杭州/成都/武汉/重庆

***

## 1. listCities

**公开** —— 选城市页拉列表 / 搜索过滤。

**请求 payload**：

| 字段 | 类型 | 必填 | 默认 | 约束 | 说明 |
|---|---|---|---|---|---|
| `level` | int | ⬜ | — | `1 \| 2 \| 3` | 1=省 / 2=市 / 3=区县（v1 seed 无 3） |
| `keyword` | string | ⬜ | — | ≤32 字符 | 搜 `name` 或 `pinyin`（"广州" / "guangzhou" 都命中） |
| `hot` | bool | ⬜ | `false` | — | `true` 返 10 热门城市（不受 level 影响） |
| `page` | int | ⬜ | `1` | ≥1 | 页码 |
| `size` | int | ⬜ | `50` | ≥1 | 每页条数（v1 全部 368 条内存，单页能拉满） |

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `cities` | \[City] | 当前页结果（结构见公共约定） |
| `total` | int | 过滤后总数（非分页大小） |

**错误码**：`40431001`（level 越界）

**示例**：

```json
// 请求
{ "action": "listCities", "payload": { "keyword": "广州", "page": 1, "size": 20 } }

// 响应
{
  "code": "0", "msg": "ok", "data": {
    "cities": [{"adcode":"440100","name":"广州市","parentCode":"440000","level":2,"pinyin":"guangzhou","isHot":true}],
    "total": 1
  }
}
```

***

## 2. selectCity

**登录** —— 绑定当前城市（D-7 双模入参）。

**请求 payload**（二选一，双传 `40431001`）：

| 模式 | 字段 | 类型 | 必填 | 约束 |
|---|---|---|---|---|
| **手动** | `adcode` | string | ✅ | `^\d{6}$`（6 位数字） |
| **GPS** | `gps.lng` | float | ✅ |  |
| `gps.lat` | float | ✅ |  |

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `adcode` | string | 6 位 adcode |
| `name` | string | 城市中文名 |
| `source` | string | `"manual"` / `"auto_gps"`（GPS 路径 → `auto_gps`，手动 → `manual`） |
| `updatedAt` | string | ISO 8601（`2026-09-29T11:06:48`） |

**错误码**：

| code | 场景 |
|---|---|
| `40431001` | 双模入参并存 / 都缺 / adcode 格式非 6 位 |
| `40431002` | adcode 不在 seed（unauthorized） |
| `40431004` | 未登录（401） |
| **`40431005`** | **GPS 转码失败（v1 不可用 —— 实际必走这条）** |

**示例**：

```json
// 手动请求
{ "action": "selectCity", "payload": { "adcode": "440100" } }

// 手动响应
{
  "code": "0", "msg": "ok", "data": {
    "adcode": "440100", "name": "广州市", "source": "manual", "updatedAt": "2026-09-29T11:06:48"
  }
}
```

```json
// GPS 请求（v1 必返 40431005）
{ "action": "selectCity", "payload": { "gps": { "lng": 113.2644, "lat": 23.1291 } } }

// GPS 响应（v1）
{ "code": "40431005", "msg": "GPS 坐标无法解析到城市" }
```

***

## 3. getCurrentCity

**登录** —— 首页 / 我的页读当前绑定城市。

**请求**：`{ "action": "getCurrentCity", "payload": {} }`

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `city` | City | 当前绑定城市（结构见公共约定） |
| `boundAt` | string | ISO 8601（首次绑定或最后一次 selectCity 时间） |
| `source` | string | `"manual"` / `"auto_gps"` |

**错误码**：

| code | 场景 | 前端处置 |
|---|---|---|
| **`40431003`** | **用户尚未绑定城市（新用户 / 清 cookie 重装）** | **跳引导页 / 选城市页**，不当错误展示 |
| `40431002` | 绑定 adcode 在 seed 找不到（数据不一致 — 走 admin 排查） | toast |
| `40431004` | 未登录 | 走登录 |

**示例**：

```json
// 已绑定用户响应
{
  "code": "0", "msg": "ok", "data": {
    "city": {"adcode":"440100","name":"广州市","parentCode":"440000","level":2,"pinyin":"guangzhou","isHot":true},
    "boundAt": "2026-09-29T11:06:48",
    "source": "manual"
  }
}

// 新用户响应（**预期**返回）
{ "code": "40431003", "msg": "尚未绑定城市" }
```

***

## 4. getRecentCities

**登录** —— 选城市页 onShow 拉最近选择 + 热门垫底。

v1 简化：每用户绑定表只有 1 条 → 「最近」= 当前绑定单条 + seed 10 热门按此去重数垫底。

**请求 payload**：

| 字段 | 类型 | 必填 | 默认 | 约束 | 说明 |
|---|---|---|---|---|---|
| `limit` | int | ⬜ | `5` | 1~50 | 返回上限 |

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `cities` | \[City] | 已有绑定前置 + 热门按 adcode 去重垫底 |

**错误码**：`40431004`（401 未登录）

**示例**：

```json
// 请求
{ "action": "getRecentCities", "payload": { "limit": 5 } }

// 响应（已绑广州市 + 热门垫底）
{
  "code": "0", "msg": "ok", "data": {
    "cities": [
      {"adcode":"440100","name":"广州市","parentCode":"440000","level":2,"pinyin":"guangzhou","isHot":true},
      {"adcode":"110100","name":"北京市","parentCode":"110000","level":2,"pinyin":"beijing","isHot":true},
      {"adcode":"310100","name":"上海市","parentCode":"310000","level":2,"pinyin":"shanghai","isHot":true},
      {"adcode":"440300","name":"深圳市","parentCode":"440000","level":2,"pinyin":"shenzhen","isHot":true},
      {"adcode":"440600","name":"佛山市","parentCode":"440000","level":2,"pinyin":"foshan","isHot":true}
    ]
  }
}
```

***

## 页面 × API 矩阵

| uniapp 页面 / 组件 | 调用的 action | 调用时机 |
|---|---|---|
| **首页** | `getCurrentCity` | 启动时查当前城市（40431003 跳引导页） |
| **选城市页** | `getRecentCities` | onShow 拉最近 + 热门垫底 |
| **选城市页 · 搜索框** | `listCities` | 输入 keyword 时实时过滤 |
| **选城市页 · 点选** | `selectCity {adcode}` | 点列表项 → 返回 + 刷新 |
| **设置页** | `getCurrentCity` | 显示当前绑定 |

## 实现参考

后端实施规范见 [🟢 Internal Wiki → ADR-0013 city 域](/internal-wiki/decisions/0013-city-domain)。
前端对接指南（推荐页面流图）见 [🟢 Internal Wiki → 2026-09-29 选择城市 + 生吃排名 前端对接指引](/internal-wiki/operations/2026-09-29-city-rawfood-frontend-handoff)。
