# 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)。