Sfoglia il codice sorgente

用户发票抬头管理

qmj 1 settimana fa
parent
commit
5fce09d8b7

File diff suppressed because it is too large
+ 25 - 0
.claude/homunculus/observations.jsonl


+ 93 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.order;
+
+import java.util.List;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoInvoice;
+import com.ruoyi.system.service.IInfoInvoiceService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 用户发票抬头管理 Controller(发票信息管理)
+ * <p>镜像 InfoAddressController 的 App 端风格:@Anonymous @Auth + token header + JWT 取 userId。
+ * 用户隔离:写库前强制 setUserId、查/改/删按 userId 归属校验。</p>
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+@Api("用户发票抬头管理")
+@RestController
+@RequestMapping("/system/invoice")
+public class InfoInvoiceController extends BaseController
+{
+    @Autowired
+    private IInfoInvoiceService infoInvoiceService;
+
+    /**
+     * 列出当前用户的全部发票抬头(按 id 倒序)。
+     * <p>每行带 category(B2C/B2B)作为类型区分依据,前端按 category 渲染不同字段。</p>
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("列出我的发票抬头(按 category 区分 B2C/B2B)")
+    @GetMapping("/getinvoice")
+    public AjaxResult getinvoice(@RequestHeader String token)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        List<InfoInvoice> list = infoInvoiceService.listByUserId(userId);
+        return success(MessageUtils.message("no.obtained.success"), list);
+    }
+
+    /**
+     * 新增或更新发票抬头(saveOrUpdate:有 id 更新、无 id 新增)。
+     * <p>入参字段随 category 变化(见 InfoInvoice 字段备注 / contracts):B2C 传载具、B2B 传统编。
+     * 字段格式由 @Valid 注解先校验,条件必填与类型互斥由 service 强校验。</p>
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("新增/修改发票抬头(按 category 决定必填字段)")
+    @PostMapping("/invoice")
+    public AjaxResult invoice(@RequestHeader String token, @Valid @RequestBody InfoInvoice infoInvoice)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        boolean ok = infoInvoiceService.saveOrUpdateMine(infoInvoice, userId);
+        return ok ? success() : error();
+    }
+
+    /**
+     * 发票抬头详情(越权 / 不存在返回 null)。
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("发票抬头详情")
+    @GetMapping("/getinvoicexq")
+    public AjaxResult getinvoicexq(@RequestHeader String token, @RequestParam Long id)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoInvoice invoice = infoInvoiceService.getMine(id, userId);
+        return success(MessageUtils.message("no.obtained.success"), invoice);
+    }
+
+    /**
+     * 删除发票抬头(越权 / 不存在返回失败)。
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("删除发票抬头")
+    @GetMapping("/deleinvoice")
+    public AjaxResult deleinvoice(@RequestHeader String token, @RequestParam Long id)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        boolean ok = infoInvoiceService.deleteMine(id, userId);
+        return ok ? success() : error("抬头不存在或无权操作");
+    }
+}

+ 80 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoInvoice.java

@@ -0,0 +1,80 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * 用户发票抬头 info_invoice(发票信息管理)—— 即请求体 / 响应体。
+ * <p>用户级 CRUD,镜像 InfoAddress;JWT 隔离 userId。字段完整说明见
+ * specs/014-invoice-profile/contracts/api.md。</p>
+ *
+ * <p><b>按 category 区分两套入参(列表返回同理,前端按 category 渲染):</b><br>
+ * B2C(个人):titleName + buyerName + carrierType + carrierNum(载具=2 时再加 buyerEmail)<br>
+ * B2B(公司):titleName + buyerName + buyerUbn + buyerEmail(不传载具)</p>
+ *
+ * <p>字段格式 / 必填由 jakarta 校验注解声明(@NotBlank/@Pattern/@Email,springfox 自动识别为
+ * Swagger 的 required/pattern 展示在 /doc.html);条件必填(B2C 载具、B2B 统编)与类型互斥
+ * 由 service validateInvoiceProfile 强校验。</p>
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+@Data
+@TableName(value = "info_invoice")
+@EqualsAndHashCode(callSuper = false)
+public class InfoInvoice
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 主键(新增不传,修改必传) */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户id(后端按登录 token 自动填充,前端无需传) */
+    private Long userId;
+
+    /** 抬头备注名(用户自定义,便于区分多条抬头) */
+    @NotBlank(message = "抬头名称不能为空")
+    private String titleName;
+
+    /** 发票类型 B2C=个人 / B2B=公司(区分入参字段与列表渲染的依据) */
+    @NotBlank(message = "发票类型不能为空")
+    @Pattern(regexp = "B2C|B2B", message = "发票类型只能为 B2C 或 B2B")
+    private String category;
+
+    /** 买方名称(B2C 填个人姓名 / B2B 填公司全名) */
+    @NotBlank(message = "买方名称不能为空")
+    private String buyerName;
+
+    /** 统一编号(统编,8 位数字);仅 B2B 必填,B2C 不传 */
+    @Pattern(regexp = "\\d{8}", message = "统一编号必须为 8 位数字")
+    private String buyerUbn;
+
+    /** 接收邮箱;B2B 必填 / B2C 仅载具类型=2(ezPay会员)时必填,其余可空 */
+    @Email(message = "邮箱格式不正确")
+    private String buyerEmail;
+
+    /** 载具类型 0手机条码 / 1自然人凭证 / 2ezPay会员;仅 B2C 必填,B2B 不传 */
+    @Pattern(regexp = "0|1|2", message = "载具类型只能为 0、1、2")
+    private String carrierType;
+
+    /** 载具号码(仅 B2C 必填,随 carrierType:0手机条码以/开头;1自然人凭证=2字母+14数字;2 ezPay会员账号) */
+    private String carrierNum;
+
+    /** 创建时间(DB DEFAULT CURRENT_TIMESTAMP,只读) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新时间(DB ON UPDATE CURRENT_TIMESTAMP,只读) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 16 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoInvoiceMapper.java

@@ -0,0 +1,16 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoInvoice;
+
+/**
+ * 用户发票抬头 Mapper(发票信息管理)
+ * <p>仅用 MyBatis-Plus IService CRUD(list/saveOrUpdate/getById/removeById),
+ * 无自定义查询、无 XML。本期无后台端。</p>
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+public interface InfoInvoiceMapper extends BaseMapper<InfoInvoice>
+{
+}

+ 35 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoInvoiceService.java

@@ -0,0 +1,35 @@
+package com.ruoyi.system.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.system.domain.InfoInvoice;
+
+import java.util.List;
+
+/**
+ * 用户发票抬头 Service(发票信息管理)
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+public interface IInfoInvoiceService extends IService<InfoInvoice>
+{
+    /**
+     * 列出当前用户的全部发票抬头(按 id 倒序)。
+     */
+    List<InfoInvoice> listByUserId(Long userId);
+
+    /**
+     * 新增或更新:强制 userId + 校验 + 归属校验;非法抛 ServiceException。
+     */
+    boolean saveOrUpdateMine(InfoInvoice invoice, Long userId);
+
+    /**
+     * 详情;越权(不属于该 user)返回 null。
+     */
+    InfoInvoice getMine(Long id, Long userId);
+
+    /**
+     * 删除;越权返回 false。
+     */
+    boolean deleteMine(Long id, Long userId);
+}

+ 163 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoInvoiceServiceImpl.java

@@ -0,0 +1,163 @@
+package com.ruoyi.system.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoInvoice;
+import com.ruoyi.system.mapper.InfoInvoiceMapper;
+import com.ruoyi.system.service.IInfoInvoiceService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * 用户发票抬头 Service 实现(发票信息管理)
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+@Service
+public class InfoInvoiceServiceImpl extends ServiceImpl<InfoInvoiceMapper, InfoInvoice> implements IInfoInvoiceService
+{
+    /** 统编:8 位数字 */
+    private static final Pattern UBN_PATTERN = Pattern.compile("^\\d{8}$");
+    /** 自然人凭证:2 大写字母 + 14 数字 */
+    private static final Pattern CARRIER_CDC_PATTERN = Pattern.compile("^[A-Z]{2}\\d{14}$");
+    /** 邮箱格式(宽松校验) */
+    private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
+
+    @Override
+    public List<InfoInvoice> listByUserId(Long userId)
+    {
+        return list(new QueryWrapper<InfoInvoice>().eq("user_id", userId).orderByDesc("id"));
+    }
+
+    @Override
+    public boolean saveOrUpdateMine(InfoInvoice invoice, Long userId)
+    {
+        // 强制以当前登录用户为准,前端传的 userId 无效(防越权)
+        invoice.setUserId(userId);
+        validateInvoiceProfile(invoice);
+        // 更新时校验归属:该抬头必须属于当前用户
+        if (invoice.getId() != null)
+        {
+            InfoInvoice exist = getById(invoice.getId());
+            if (exist == null || !userId.equals(exist.getUserId()))
+            {
+                throw new ServiceException("抬头不存在或无权操作");
+            }
+        }
+        return saveOrUpdate(invoice);
+    }
+
+    @Override
+    public InfoInvoice getMine(Long id, Long userId)
+    {
+        InfoInvoice invoice = getById(id);
+        if (invoice == null || !userId.equals(invoice.getUserId()))
+        {
+            return null;
+        }
+        return invoice;
+    }
+
+    @Override
+    public boolean deleteMine(Long id, Long userId)
+    {
+        InfoInvoice invoice = getById(id);
+        if (invoice == null || !userId.equals(invoice.getUserId()))
+        {
+            return false;
+        }
+        return removeById(id);
+    }
+
+    /**
+     * 保存前中校验(见 contracts/api.md 校验矩阵)。
+     * B2B:公司名 + 统编8位 + 邮箱必填 + 载具空。
+     * B2C:姓名 + 载具必填(0手机条码以/开头、1自然人凭证2字母+14数字、2会员载具须带邮箱)+ 统编空。
+     */
+    private void validateInvoiceProfile(InfoInvoice invoice)
+    {
+        String category = invoice.getCategory();
+        if (!"B2C".equals(category) && !"B2B".equals(category))
+        {
+            throw new ServiceException("发票类型不正确");
+        }
+        if (isBlank(invoice.getTitleName()))
+        {
+            throw new ServiceException("抬头名称不能为空");
+        }
+        if (isBlank(invoice.getBuyerName()))
+        {
+            throw new ServiceException("买方名称不能为空");
+        }
+
+        if ("B2B".equals(category))
+        {
+            if (invoice.getBuyerUbn() == null || !UBN_PATTERN.matcher(invoice.getBuyerUbn()).matches())
+            {
+                throw new ServiceException("统一编号须为8位数字");
+            }
+            if (isBlank(invoice.getBuyerEmail()) || !EMAIL_PATTERN.matcher(invoice.getBuyerEmail()).matches())
+            {
+                throw new ServiceException("邮箱格式不正确");
+            }
+            if (!isBlank(invoice.getCarrierType()) || !isBlank(invoice.getCarrierNum()))
+            {
+                throw new ServiceException("公司发票无需载具");
+            }
+            return;
+        }
+
+        // B2C
+        if (!isBlank(invoice.getBuyerUbn()))
+        {
+            throw new ServiceException("个人发票无需统一编号");
+        }
+        String carrierType = invoice.getCarrierType();
+        if (isBlank(carrierType))
+        {
+            throw new ServiceException("请选择载具类型");
+        }
+        if (isBlank(invoice.getCarrierNum()))
+        {
+            throw new ServiceException("载具号码不能为空");
+        }
+        String carrierNum = invoice.getCarrierNum();
+        switch (carrierType)
+        {
+            case "0": // 手机条码:以 / 开头
+                if (!carrierNum.startsWith("/"))
+                {
+                    throw new ServiceException("手机条码须以 / 开头");
+                }
+                break;
+            case "1": // 自然人凭证:2 字母 + 14 数字
+                if (!CARRIER_CDC_PATTERN.matcher(carrierNum).matches())
+                {
+                    throw new ServiceException("自然人凭证格式不正确");
+                }
+                break;
+            case "2": // ezPay 会员载具:须带邮箱
+                if (isBlank(invoice.getBuyerEmail()) || !EMAIL_PATTERN.matcher(invoice.getBuyerEmail()).matches())
+                {
+                    throw new ServiceException("会员载具须填写邮箱");
+                }
+                break;
+            default:
+                throw new ServiceException("载具类型不正确");
+        }
+        // 邮箱选填时也校验格式
+        if (!isBlank(invoice.getBuyerEmail()) && !EMAIL_PATTERN.matcher(invoice.getBuyerEmail()).matches())
+        {
+            throw new ServiceException("邮箱格式不正确");
+        }
+    }
+
+    private static boolean isBlank(String s)
+    {
+        return s == null || s.trim().isEmpty();
+    }
+}

+ 120 - 0
specs/014-invoice-profile/contracts/api.md

@@ -0,0 +1,120 @@
+# 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 仍做最终校验兜底。

+ 36 - 0
specs/014-invoice-profile/data-model.md

@@ -0,0 +1,36 @@
+# Data Model: 用户发票抬头管理
+
+**Date**: 2026-07-24
+
+## 新增表:info_invoice(用户发票抬头,用户级,镜像 info_address)
+
+> DDL 写入 `updatesql/sql.md`,由开发者手动执行(项目规范)。用户级数据用 `info_` 前缀(与 `info_address` / `info_user` 一致),区别于订单级 `pos_order_invoice`。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| user_id | BIGINT NOT NULL | 用户id(JWT 隔离;写库前强制以 token 解析值覆盖) |
+| title_name | VARCHAR(50) NOT NULL | 抬头备注名(如「公司-美食達」「个人-手机条码」),客户端列表展示用 |
+| category | VARCHAR(8) NOT NULL | 类型:`B2C`=个人 / `B2B`=公司 |
+| buyer_name | VARCHAR(100) NOT NULL | 买方名称:B2C=个人姓名 / B2B=公司全名 |
+| buyer_ubn | VARCHAR(16) | 统一编号(统编,8 位数字,含前导零须字符串);**仅 B2B** |
+| buyer_email | VARCHAR(200) | 接收邮箱;B2B 必填 / B2C 仅 ezPay 会员载具(2) 时必填 |
+| carrier_type | VARCHAR(8) | 载具类型 `0`手机条码 / `1`自然人凭证 / `2`ezPay 会员;**仅 B2C** |
+| carrier_num | VARCHAR(64) | 载具号码(随 carrier_type) |
+| create_time | DATETIME DEFAULT CURRENT_TIMESTAMP | 创建时间(DB 自动) |
+| update_time | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间(DB 自动) |
+
+> 不设 `create_by` / `update_by`:App 端走 JWT(非 ruoyi 登录用户名),无意义字段,从简。`create_time` / `update_time` 由 DB `DEFAULT` / `ON UPDATE` 自动维护,代码不手动赋值。
+
+**索引**:`KEY idx_user (user_id)`(用户隔离查询)。
+
+**约束约定**(应用层校验,非 DB 约束):
+
+- 无默认抬头列、无数量上限、硬删除(与 `info_address` 一致)。
+- B2B ↔ 载具字段互斥:B2B 时 `carrier_type` / `carrier_num` 必空;B2C 时 `buyer_ubn` 必空。
+- 完整保存校验矩阵见 `contracts/api.md`。
+
+## 复用实体(不改)
+
+- **InfoUser**(`info_user`):提供 userId(JWT claim `id` → `JwtUtil.getusid`)。
+- **010 `ApplyInvoiceDto` / `PosOrderInvoice`**:不改。开票时客户端从选中抬头取字段填入 `ApplyInvoiceDto`,开票链路完全不动。

+ 42 - 0
specs/014-invoice-profile/plan.md

@@ -0,0 +1,42 @@
+# Implementation Plan: 用户发票抬头管理
+
+**Date**: 2026-07-24
+
+## 定位与决策
+
+- **镜像 `InfoAddress`**(收货地址)用户级 CRUD 模式:实体即 DTO/VO、JWT userId 隔离、App 端接口风格(`@Anonymous @Auth` + token header)、无默认 / 无上限 / 硬删除。
+- **仅 App 端 CRUD**:不建后台端接口、不建 sys_menu、不建 Vue 页面、无 i18n(按需求决策,YAGNI)。
+- **与 010 解耦**:不改 010 `applyInvoice` / `getInvoice`;抬头是纯输入辅助,开票时客户端选用预填。
+- **校验在 service 层**(`validateInvoiceProfile`),中校验力度:必填 + 统编 8 位 + 载具正则。
+- **捐赠(DONATION)不存为抬头**;B2C 必须有载具(对齐 010 当前规则)。
+
+## 文件落点
+
+| 层 | 路径 |
+|---|---|
+| 实体 | `ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoInvoice.java` |
+| Mapper 接口 | `ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoInvoiceMapper.java` |
+| Mapper XML | `ruoyi-system/src/main/resources/mapper/system/InfoInvoiceMapper.xml` |
+| Service 接口 | `ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoInvoiceService.java` |
+| Service 实现 | `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoInvoiceServiceImpl.java` |
+| Controller | `ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java` |
+| SQL | `updatesql/sql.md`(追加 2026-07-24 段) |
+
+> Mapper XML 放 `mapper/system/`(与 `InfoAddressMapper.xml` 同位),非 `mapper/chanting/`(那是 pos_ 业务表)。
+
+## 分阶段
+
+1. **Setup**:DDL 写 `updatesql/sql.md`(开发者手动执行)。
+2. **Foundational**:实体 + Mapper(+XML) + Service(+校验)。
+3. **Controller**:App 端 4 接口。
+4. **Polish**:curl 手测(B2C / B2B / 校验 / 越权)+ 更新记忆索引。
+
+## 测试策略
+
+轻量。无单测强制;按 `contracts/api.md` 用 curl/Postman 手测 4 条接口 + 校验矩阵各分支 + 越权拒绝。
+
+## 风险与注意
+
+- `InfoAddressServiceImpl` 的 `ServiceImpl` 泛型用的是 `BaseMapper<InfoAddress>`(历史遗留);新建 `InfoInvoiceServiceImpl` 时用具体 `ServiceImpl<InfoInvoiceMapper, InfoInvoice>`。
+- `@Auth` 来源 `com.ruoyi.system.utils.Auth`、`@Anonymous` 来源 `com.ruoyi.common.annotation.Anonymous`、`JwtUtil` 来源 `com.ruoyi.system.utils.JwtUtil`(均照搬 InfoAddressController)。
+- 后端文件亦为 CRLF(见 memory `project-build-env`);新建文件用项目默认换行即可,编辑既有文件注意 CRLF。

+ 91 - 0
specs/014-invoice-profile/spec.md

@@ -0,0 +1,91 @@
+# 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)。

+ 62 - 0
specs/014-invoice-profile/tasks.md

@@ -0,0 +1,62 @@
+---
+
+description: "Task list for 用户发票抬头管理"
+
+---
+
+# Tasks: 用户发票抬头管理
+
+**Input**: Design documents from `/specs/014-invoice-profile/`
+
+**Prerequisites**: spec.md ✅, data-model.md ✅, contracts/api.md ✅, plan.md ✅
+
+**范围**: 仅后端 App 端 CRUD(实体 / Mapper / Service / Controller + SQL)。无前端、无 i18n、无后台端。
+
+**测试**: 轻量手测(curl/Postman),按 contracts/api.md。
+
+## Format: `[ID] [P?] Description`
+
+- **[P]**: 可并行(不同文件、无未完成依赖)
+
+## Path Conventions
+
+- 实体 / Mapper / Service:`ruoyi-system/src/main/java/com/ruoyi/system/...`
+- Controller:`ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java`
+- SQL:`updatesql/sql.md`
+
+---
+
+## Phase 1: Setup
+
+- [x] T001 在 `updatesql/sql.md` 追加 2026-07-24 段:`CREATE TABLE info_invoice`(字段 / 类型 / 注释见 data-model.md,含 `KEY idx_user(user_id)`,引擎 InnoDB / utf8mb4)。无 sys_menu(本期无后台端)。
+
+---
+
+## Phase 2: Foundational
+
+- [x] T002 [P] 新建 `InfoInvoice` 实体(`ruoyi-system/.../domain/InfoInvoice.java`):`@Data @TableName("info_invoice") @EqualsAndHashCode(callSuper=false)`,`@TableId(type=IdType.AUTO)`;字段 id / userId / titleName / category / buyerName / buyerUbn / buyerEmail / carrierType / carrierNum / createTime / updateTime(注释对齐 data-model;create_time/update_time 由 DB DEFAULT/ON UPDATE 自动维护,无 createBy/updateBy)。参考 `InfoAddress.java` 风格。
+- [x] T003 [P] 新建 `InfoInvoiceMapper` 接口(`ruoyi-system/.../mapper/InfoInvoiceMapper.java`)继承 `BaseMapper<InfoInvoice>`,**空体无自定义方法、无 XML**——App 端仅用 MyBatis-Plus `IService` CRUD(list/saveOrUpdate/getById/removeById,字段驼峰自动映射 info_invoice 列);本期无后台端,故无需自定义查询。
+- [x] T004 新建 `IInfoInvoiceService`(`ruoyi-system/.../service/IInfoInvoiceService.java`,继承 `IService<InfoInvoice>`,方法 listByUserId / saveOrUpdateMine / getMine / deleteMine)+ `InfoInvoiceServiceImpl`(`.../service/impl/InfoInvoiceServiceImpl.java`,`extends ServiceImpl<InfoInvoiceMapper, InfoInvoice>`)。实现私有 `validateInvoiceProfile(InfoInvoice)`(按 contracts 校验矩阵:titleName / buyerName 非空、category ∈ {B2C,B2B}、B2B 统编 `^\d{8}$` + 邮箱必填 + 载具空、B2C 载具按类型正则 + 统编空 + 邮箱仅会员载具必填),非法抛 `ServiceException`。`saveOrUpdateMine` 内强制 setUserId + 校验 + 更新时归属校验;`getMine`/`deleteMine` 越权返回 null/false。依赖 T002、T003。
+
+**Checkpoint**: 表 + 实体 + mapper + service(含校验)就绪。
+
+---
+
+## Phase 3: Controller(App 端)
+
+- [x] T005 新建 `InfoInvoiceController`(`ruoyi-admin/.../app/order/InfoInvoiceController.java`,`@RequestMapping("/system/invoice")`,extends `BaseController`,注入 `IInfoInvoiceService`):① `GET /getinvoice`(`@Anonymous @Auth` + `@RequestHeader token` → `getusid` → `QueryWrapper.eq("user_id")` → list);② `POST /invoice`(token + `@RequestBody InfoInvoice` → `setUserId` 强制覆盖 → `validateInvoiceProfile` → 更新时校验归属 → `saveOrUpdate`);③ `GET /getinvoicexq?id=`(getById + 归属校验);④ `GET /deleinvoice?id=`(归属校验 → removeById)。注解 / 工具类照搬 `InfoAddressController`。依赖 T004。
+
+**Checkpoint**: 4 接口可用,curl 跑通。
+
+---
+
+## Phase 4: Polish
+
+- [ ] T006 [P] 按 contracts/api.md 手测:B2C(三种载具)/ B2B 保存 → 列表 → 详情 → 改 → 删;校验矩阵各非法分支(统编非 8 位、手机条码不以 `/` 开头、自然人凭证格式错、B2B 带载具、B2C 带统编、必填空)均拦截;越权(A 操作 B 的 id)拒绝。
+- [x] T007 [P] 更新记忆索引 `MEMORY.md`:加一行指向 `specs/014-invoice-profile/spec.md`(参考 010 写法)。
+
+---
+
+## Dependencies & Execution Order
+
+- Phase 1 Setup → Phase 2 Foundational(T002 / T003 可并行,T004 依赖二者)→ Phase 3 Controller(T005 依赖 T004)→ Phase 4 Polish(T006 依赖 T005,T007 随时)。

+ 21 - 0
updatesql/sql.md

@@ -400,3 +400,24 @@ ALTER TABLE pos_order_invoice
 -- 捐赠机构字典 pos_love_org 表 + 2016 行数据见 updatesql/pos_love_org.sql
 -- (由 specs/010-order-invoice/gen_love_org_sql.py 从财政部 CSV 生成,需另执行)
 ```
+
+## 2026-07-24 用户发票抬头管理(014-invoice-profile)
+
+```sql
+-- 用户发票抬头表(用户级,镜像 info_address;区别于订单级 pos_order_invoice)
+CREATE TABLE info_invoice (
+  id            BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  user_id       BIGINT       NOT NULL                COMMENT '用户id(JWT隔离)',
+  title_name    VARCHAR(50)  NOT NULL                COMMENT '抬头备注名(如 公司-美食達)',
+  category      VARCHAR(8)   NOT NULL                COMMENT '类型 B2C=个人/B2B=公司',
+  buyer_name    VARCHAR(100) NOT NULL                COMMENT '买方名称 B2C=个人姓名 B2B=公司名',
+  buyer_ubn     VARCHAR(16)  DEFAULT NULL            COMMENT '统一编号(统编,8位数字,含前导零);仅B2B',
+  buyer_email   VARCHAR(200) DEFAULT NULL            COMMENT '邮箱;B2B必填/B2C仅会员载具必填',
+  carrier_type  VARCHAR(8)   DEFAULT NULL            COMMENT '载具类型 0手机条码/1自然人凭证/2ezPay会员;仅B2C',
+  carrier_num   VARCHAR(64)  DEFAULT NULL            COMMENT '载具号码',
+  create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (id),
+  KEY idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户发票抬头(发票信息管理)';
+```

Some files were not shown because too many files changed in this diff