# API Contracts: 用户发票抬头管理

**Date**: 2026-07-24

后端 REST 接口契约。挂在 `InfoInvoiceController`（`@RequestMapping("/system/invoice")`），风格镜像 `InfoAddressController`（`@Anonymous @Auth` + `@RequestHeader String token` + `JwtUtil.getusid(token)` 取 userId）。实体 `InfoInvoice` 即请求体 / 响应体（无专门 DTO/VO，与 InfoAddress 一致）。

> 字段格式 / 必填由 jakarta 校验注解（`@NotBlank`/`@Pattern`/`@Email`）标在 `InfoInvoice` 实体上，springfox 自动识别为 Swagger 的 required/pattern（前端在 Knife4j `/doc.html` 可见字段约束）；接口与字段完整含义、示例、按类型的必填条件见本文件。Controller 另用 `@Api`/`@ApiOperation` 给出接口描述。
> 说明：实体在 `ruoyi-system` 模块（无 swagger 依赖），故字段级描述走校验注解 + 本文档，与 010 `ApplyInvoiceDto` 同一约定。

## 核心约定：按 category 区分两套字段

`category` 是类型区分依据（新增/修改的入参分支、列表渲染分支都以它为准）：

| category | 含义 | 必传字段 | 不传字段 |
|---|---|---|---|
| **B2C** | 个人发票 | titleName、buyerName、carrierType、carrierNum（载具=2 时再加 buyerEmail） | buyerUbn |
| **B2B** | 公司发票 | titleName、buyerName、buyerUbn、buyerEmail | carrierType、carrierNum |

---

## App 端接口（JWT 鉴权，userId 强制隔离）

### GET `/system/invoice/getinvoice`

列出当前用户全部抬头（按 id 倒序）。

**入参**：`@RequestHeader String token`。

**响应** `AjaxResult` data：`List<InfoInvoice>`。**前端按每行的 `category` 区分类型并渲染对应字段**：

| 字段 | B2C 行 | B2B 行 |
|---|---|---|
| `category` | `"B2C"` | `"B2B"` ← 类型判别字段 |
| `titleName` | 有 | 有 |
| `buyerName` | 个人姓名 | 公司名 |
| `buyerUbn` | `null`（不展示） | 统编（展示） |
| `buyerEmail` | 载具=2 时有值，否则 `null` | 邮箱（展示） |
| `carrierType` | `0`/`1`/`2`（展示载具） | `null`（不展示） |
| `carrierNum` | 载具号码（展示） | `null`（不展示） |

> 渲染建议：列表项以 `titleName` 为主标题，副标题按 category 走——B2B 显示「公司名 · 统编」，B2C 显示「姓名 · 载具类型描述」。

### POST `/system/invoice/invoice`

新增或更新（`saveOrUpdate`：有 id 走更新、无 id 走新增）。

**入参**：`@RequestHeader String token` + `@RequestBody InfoInvoice`（字段格式由 `@Valid` 先校验、条件必填与类型互斥由 service 强校验）。

**完整字段表（前端据此传参）**

| 字段 | 类型 | 含义 | 格式 / 示例 | B2C | B2B |
|---|---|---|---|---|---|
| id | Long | 主键 | 新增不传；修改必传（`101`） | 同左 | 同左 |
| titleName | String | 抬头备注名 | 任意，`公司-美食達` | ✅必填 | ✅必填 |
| category | String | 发票类型 | `B2C` / `B2B` | ✅必填=`B2C` | ✅必填=`B2B` |
| buyerName | String | 买方名称 | B2C 个人姓名 / B2B 公司名 | ✅必填 | ✅必填 |
| buyerUbn | String | 统一编号 | 8 位数字 `12345678` | ❌不传 | ✅必填 |
| buyerEmail | String | 邮箱 | `ms@example.com` | ⚪载具=2 时必填，其余可空 | ✅必填 |
| carrierType | String | 载具类型 | `0`手机条码/`1`自然人凭证/`2`ezPay会员 | ✅必填 | ❌不传 |
| carrierNum | String | 载具号码 | 随 carrierType（见下） | ✅必填 | ❌不传 |
| userId | Long | 用户id | 后端按 token 自动填，**前端不传** | — | — |

**载具号码格式（carrierNum，随 carrierType）**

- `0` 手机条码：以 `/` 开头，如 `/ABC1234`
- `1` 自然人凭证：`2 位大写字母 + 14 位数字`，如 `AB12345678901234`
- `2` ezPay 会员载具：会员账号（非空），且须同时带 `buyerEmail`

**处理**：① `getusid(token)` → `setUserId` 强制覆盖（防越权）；② `@Valid` 字段格式校验；③ `validateInvoiceProfile` 条件必填 + 类型互斥强校验；④ 更新时校验该 id 属于当前 user；⑤ `saveOrUpdate`。

**响应**：成功 `{code:200, data:id}`；校验失败 `{code:500, msg:"<具体原因>"}`。

**请求示例**

B2C / 手机条码载具：
```json
{ "titleName": "个人-手机条码", "category": "B2C", "buyerName": "王小明", "carrierType": "0", "carrierNum": "/ABC1234" }
```

B2C / ezPay 会员载具（须带邮箱）：
```json
{ "titleName": "个人-会员载具", "category": "B2C", "buyerName": "王小明", "carrierType": "2", "carrierNum": "C1597485444", "buyerEmail": "xm@example.com" }
```

B2B 公司：
```json
{ "titleName": "公司-美食達", "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678", "buyerEmail": "ms@example.com" }
```

修改（带 id，覆盖该条）：
```json
{ "id": 101, "titleName": "公司-美食達", "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678", "buyerEmail": "ms@example.com" }
```

### GET `/system/invoice/getinvoicexq?id=`

详情；越权（id 不属于当前 user）返回 `data: null`。

**响应** `AjaxResult` data：单条 `InfoInvoice`（字段同列表单行）。

### GET `/system/invoice/deleinvoice?id=`

删除（硬删除）；越权 / 不存在返回失败 `{code:500, msg:"抬头不存在或无权操作"}`。

---

## 保存校验矩阵（service `validateInvoiceProfile`）

| 场景 | category | buyerName | buyerUbn | buyerEmail | carrierType + carrierNum |
|------|----------|-----------|----------|------------|--------------------------|
| B2C / 手机条码(0) | B2C | ✅必填 | ❌空 | ⚪可选 | ✅必填（0 + 号以 `/` 开头） |
| B2C / 自然人凭证(1) | B2C | ✅必填 | ❌空 | ⚪可选 | ✅必填（1 + `^[A-Z]{2}\d{14}$`） |
| B2C / ezPay 会员(2) | B2C | ✅必填 | ❌空 | ✅必填 | ✅必填（2 + 号非空） |
| B2B 公司 | B2B | ✅必填（公司名） | ✅必填（`^\d{8}$`） | ✅必填（邮箱格式） | ❌空 |

通用：`titleName` 非空；`category ∈ {B2C, B2B}`；`buyerEmail` 若填则校验邮箱格式。

## 与 010 开票的对接（客户端驱动，不改后端开票）

客户端开票时：① `GET /system/invoice/getinvoice` 拉抬头；② 用户选一条 → 客户端按字段映射填入 `ApplyInvoiceDto`（category / buyerName / buyerUbn / buyerEmail / carrierType / carrierNum）→ ③ `POST /system/userOrder/applyInvoice`（010，不改）。抬头仅作输入快捷方式，010 仍做最终校验兜底。
