# Feature Specification: 用户发票抬头管理（发票信息管理）

**Feature Branch**: 不新建分支（当前 test 分支开发）

**Created**: 2026-07-24

**Status**: Draft

**Input**: User description: "开发票时用户每次都要手填手机码（载具）等信息，做一个像收货地址管理一样的功能，让用户保存常用发票抬头、开票时直接选用、不必重填。"

## 背景与定位

010（订单 ezPay 发票开立）已实现订单级即时开票，客户每次开票需在 `ApplyInvoiceDto` 手填买方名称 / 统编 / 邮箱 / 载具等。本期新增**用户级发票抬头 CRUD**（镜像 `InfoAddress` 收货地址管理模式），客户保存常用 B2C / B2B 抬头，开票时由客户端选用并预填，**后端开票链路（010）完全不改**。

范围 = **后端 App 端 CRUD 接口**；客户端 App 的「我的抬头」管理页 + 订单开票时的「选用抬头」入口由客户端团队后续对接（沿用 010 分工）。本期无任何前端页面，故无 i18n 改动。

## User Scenarios & Testing *(mandatory)*

### User Story 1 - 客户管理个人（B2C）发票抬头 (Priority: P1)

客户在 App 新增 / 查看 / 编辑 / 删除个人发票抬头，含姓名 + 载具（手机条码 / 自然人凭证 / ezPay 会员，必填）+ 邮箱（会员载具时必填）。

**Why this priority**: 个人发票是外卖/餐饮场景最高频的开票类型，与 B2B 同为核心 CRUD 路径。

**Independent Test**: 调 `POST /system/invoice/invoice`（B2C + 手机条码载具）→ 成功 → `GET /getinvoice` 列出该条 → `GET /deleinvoice?id=` 删除成功。

**Acceptance Scenarios**:

1. **Given** 客户带 token，**When** 提交 B2C 抬头（姓名 + 手机条码载具），**Then** 保存成功，列表/详情可见。
2. **Given** 客户提交 B2C 抬头但未带载具，**When** 提交，**Then** 校验拦截、不入库。
3. **Given** 客户带他人抬头 id，**When** 调详情/改/删，**Then** 因 userId 不匹配被拒绝。

---

### User Story 2 - 客户管理公司（B2B）发票抬头 (Priority: P1)

客户保存公司抬头，含公司名 + 统编（8 位）+ 邮箱。

**Why this priority**: B2B 报账是刚需（统编标配），与个人开票同为核心路径。

**Independent Test**: `POST /invoice`（B2B + 公司名 + 合法统编 + 邮箱）→ 成功；非法统编 → 拦截。

**Acceptance Scenarios**:

1. **Given** 客户提交 B2B 抬头（公司名 + 合法统编 + 邮箱），**When** 提交，**Then** 保存成功。
2. **Given** 客户填了非 8 位数字的统编，**When** 提交，**Then** 校验拦截、不入库。

---

### Edge Cases

- **越权**：用户 A 带用户 B 抬头 id 调详情/改/删 → 拒绝（userId 不匹配，返回空或失败）。
- **载具号格式非法**（手机条码不以 `/` 开头、自然人凭证非 `2 字母 + 14 数字`）→ 保存时拦截。
- **类型互斥**：B2B 带载具字段、或 B2C 带统编 → 校验拒绝（载具仅 B2C、统编仅 B2B）。
- **token 伪造 userId**：写库前以 JWT 解析的 userId 强制覆盖，前端传的 userId 无效。
- **删除/改不存在的 id**：返回未命中，不抛异常。
- **ezPay 会员载具(2) 未带邮箱**：B2C 选会员载具时邮箱必填，未带则拦截（对齐 010 规则）。

## Requirements *(mandatory)*

### Functional Requirements

- **FR-001**: 提供客户 App 端接口（JWT 鉴权、userId 隔离）对发票抬头做新增 / 查列表 / 查详情 / 改 / 删。
- **FR-002**: 抬头按 `category`（`B2C` / `B2B`）区分；字段随类型（见 data-model.md、contracts/api.md 校验矩阵）。
- **FR-003**: 保存时**中校验**——必填非空 + B2B 统编 `^\d{8}$` + 载具号码按类型正则（手机条码以 `/` 开头、自然人凭证 `^[A-Z]{2}\d{14}$`、ezPay 会员非空）+ 邮箱格式。
- **FR-004**: 用户隔离靠 JWT：`JwtUtil.getusid(token)` 取 userId，写库前 `setUserId` 强制覆盖、查/改/删按 `user_id` 过滤；越权操作拒绝。
- **FR-005**: 不改 010 的 `applyInvoice` / `getInvoice`；抬头仅为客户端开票时的输入快捷方式。
- **FR-006**: 无默认抬头、无数量上限、硬删除（与 InfoAddress 一致）。
- **FR-007**: 所有 SQL 变更写入 `updatesql/sql.md`，不直接执行（项目规范）。

### Key Entities

- **发票抬头（新增，用户级，`info_invoice`）**：titleName / category / buyerName / buyerUbn / buyerEmail / carrierType / carrierNum + userId + 审计列。
- **InfoUser（已有）**：userId 来源（JWT claim `id`）。
- **010 `ApplyInvoiceDto`（不改）**：开票时客户端从选中抬头取字段填入。

## Success Criteria *(mandatory)*

- **SC-001**: 客户能通过 App 端接口完整增删改查自己的 B2C / B2B 抬头。
- **SC-002**: 保存时格式非法（统编非 8 位、载具号不合规、必填空、类型互斥）100% 被拦截、不入库。
- **SC-003**: 用户 A 100% 无法查 / 改 / 删用户 B 的抬头（userId 隔离）。
- **SC-004**: 客户端能拉抬头列表（接口就绪），开票时选用预填现有 `ApplyInvoiceDto`（UI 由客户端团队对接）。

## Assumptions

- 镜像 `InfoAddress`（收货地址）用户级 CRUD 模式：实体即 DTO/VO、JWT 隔离、无默认、无上限、硬删除、App 端接口风格（`@Anonymous @Auth` + `@RequestHeader token`）。
- 客户端 App「我的抬头」管理页 + 订单开票时的「选用抬头」入口由客户端团队后续对接，不在本期；本期无任何前端页面，故无 i18n 改动。
- B2C 抬头必须有载具（对齐 010 当前 `applyInvoice` 规则：B2C 载具必填，0/1/2 三选一；ezPay 会员载具(2) 还须带邮箱）。
- 捐赠（DONATION）不作为可存抬头类型（按需求决策）；捐赠仍走开票时现选。
- 抬头是纯输入辅助：开票仍由 010 `applyInvoice` 接收显式字段并做最终校验兜底，即使抬头存了也会在开票时再校验一次。
- 仅交付后端 App 端 CRUD；不建后台端管理接口、不建 sys_menu、不建任何 Vue 页面（按需求决策，YAGNI）。
