13 KiB
发票平台账号配置设计
背景
当前发票模块已经有三类配置和业务数据:
biz_invoice:开票业务配置,按水司账户维护供应商、开票限额、开票类型、自动开票、扩展参数。biz_invoice_taxrate:税收分类编码、税率、规格单位等明细配置。biz_cust_invoice:客户发票抬头、税号、开户行、地址电话等客户资料。
这几类配置可以支撑“能不能开票、怎么组成发票明细、给谁开票”,但没有独立维护“对接哪个发票平台账号、平台地址、APPID、销方税号、回调地址、密钥引用”的能力。诺诺联调已经证明接口链路可通,但当前实现仍偏向代码或环境变量配置,不适合运营后台长期维护,也不适合未来接入方欣、百望、航信、用友等其他供应商。
目标
- 把发票平台账号做成通用配置,不绑定诺诺专有表结构。
- 允许一个水司账户配置多个发票平台账号,并由
biz_invoice业务配置选择默认账号。 - 发票申请、查询、作废、冲红都能回溯当时使用的平台账号,避免后续配置变更影响已开票记录。
- 密钥不落业务库,只在业务库保存
secret_ref,实际密钥从环境变量、Nacos、KMS 或 Vault 解析。 - 保持现有
/business/invoice配置语义,让业务开票规则和平台账号解耦。
不纳入本次范围
- 不一次性实现所有外部供应商客户端。
- 不重做发票税率、客户抬头、费用组成等既有配置。
- 不迁移历史发票记录的平台账号归属;历史记录可以继续按已有字段展示和查询。
- 不把真实 APPKEY、私钥、证书内容保存到数据库。
核心概念
| 概念 | 存储位置 | 说明 |
|---|---|---|
供应商模板 supplier |
biz_invoice.supplier |
现有枚举 InvoiceTemplateEnum,描述业务采用哪套开票模板或供应商类型。 |
平台编码 platform_code |
新平台账号表、biz_invoice、biz_invoice_record |
稳定技术路由键,例如 NUONUO、FANGXIN、BAIWANG、HANGXIN。 |
平台账号编码 platform_account_code |
新平台账号表、biz_invoice、biz_invoice_record |
同一平台下的账号标识,例如 NUONUO_STAGE_339901999999142。 |
平台密钥引用 app_key_secret_ref |
新平台账号表 | 指向外部密钥来源的引用值,不保存密钥明文。 |
供应商模板和平台编码不强制一一对应。比如 NUONUO_QUANDIAN 和 NUONUO 可以复用 platform_code=NUONUO,通过模板字段决定业务明细映射,通过平台编码决定调用哪个技术客户端。
数据模型
新增表 biz_invoice_platform_account
建议新增 PostgreSQL DDL:
CREATE TABLE IF NOT EXISTS biz_invoice_platform_account (
id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
platform_code VARCHAR(32) NOT NULL,
platform_account_code VARCHAR(64) NOT NULL,
account_name VARCHAR(100) NOT NULL,
base_url VARCHAR(255) NOT NULL,
version VARCHAR(32),
app_id VARCHAR(128) NOT NULL,
app_key_secret_ref VARCHAR(255) NOT NULL,
seller_taxnum VARCHAR(50) NOT NULL,
company_code VARCHAR(64),
callback_url VARCHAR(512),
default_buyer_email VARCHAR(100),
default_buyer_mobile VARCHAR(20),
extra_properties TEXT,
status SMALLINT NOT NULL DEFAULT 1,
tenant_id BIGINT,
creator VARCHAR(64),
create_time TIMESTAMP NOT NULL DEFAULT now(),
updater VARCHAR(64),
update_time TIMESTAMP NOT NULL DEFAULT now(),
deleted SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_invoice_platform_account_code
ON biz_invoice_platform_account(account_id, platform_code, platform_account_code)
WHERE deleted = 0;
CREATE INDEX IF NOT EXISTS idx_invoice_platform_account_account
ON biz_invoice_platform_account(account_id, status)
WHERE deleted = 0;
字段说明:
| 字段 | 用途 |
|---|---|
account_id |
水司账户 ID,和现有开票配置保持同一归属维度。 |
platform_code |
技术平台编码,作为客户端注册和路由键。 |
platform_account_code |
账号编码,业务配置和发票记录只引用这个编码。 |
base_url |
平台环境地址,例如诺诺测试环境 https://cmp-stage.nntest.cn。 |
version |
平台接口版本,诺诺当前使用 1.0.0。 |
app_id |
平台分配的应用标识。 |
app_key_secret_ref |
密钥引用,例如 env:NUONUO_STAGE_APP_KEY 或 nacos:invoice.nuonuo.stage.app-key。 |
seller_taxnum |
销方税号。 |
company_code |
平台侧企业编码,有的平台不需要时为空。 |
callback_url |
平台回调地址。 |
extra_properties |
平台差异化配置,JSON 字符串,例如门店编码、开票员、收款人、复核人。 |
status |
启停状态,1 启用,0 停用。 |
扩展 biz_invoice
在现有开票业务配置表增加默认平台账号绑定:
ALTER TABLE biz_invoice ADD COLUMN IF NOT EXISTS platform_code VARCHAR(32);
ALTER TABLE biz_invoice ADD COLUMN IF NOT EXISTS platform_account_code VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_biz_invoice_platform_route
ON biz_invoice(account_id, supplier, invoice_type, platform_code, platform_account_code)
WHERE deleted = 0;
biz_invoice 继续表示开票规则。新增字段只表示这条业务规则默认走哪个平台账号。
复用 biz_invoice_record
发票记录中已有或计划中的 platform_code、platform_account_code 字段继续保留。申请成功或进入平台处理中时,记录本次使用的平台编码和账号编码。后续查询、作废、冲红优先使用发票记录上的平台字段,不再根据当前业务配置重新选择账号。
密钥方案
数据库只保存 app_key_secret_ref,不保存真实密钥。后端增加统一解析接口:
public interface InvoicePlatformSecretProvider {
String resolve(String secretRef);
}
第一阶段支持两种引用:
| 引用格式 | 解析来源 | 用途 |
|---|---|---|
env:NUONUO_STAGE_APP_KEY |
系统环境变量 | 本地联调、流水线、临时测试。 |
nacos:invoice.nuonuo.stage.app-key |
应用配置中心属性 | 测试环境、生产环境。 |
后续接入 KMS 或 Vault 时扩展该接口,不影响业务表结构和平台账号表结构。日志中禁止打印 appKey、解密后的密钥、secret_ref 解析结果。
路由流程
开票申请
- 根据
chargeIds校验费用归属,并解析水司账户accountId。 - 读取
biz_invoice中启用的业务配置,匹配accountId、invoiceType,并优先使用请求中的supplier。 - 如果请求没有传
supplier,且同一accountId + invoiceType只有一条启用配置,则使用该配置。 - 如果请求没有传
supplier,且存在多条启用配置,返回明确业务异常,要求前端传供应商模板。 - 使用业务配置中的
platform_code、platform_account_code查询启用的平台账号。 - 从
InvoicePlatformClientRegistry按platform_code获取客户端。 - 调用平台客户端,写入
biz_invoice_record.platform_code、biz_invoice_record.platform_account_code、sys_request_no和平台返回状态。
查询、作废、冲红
- 根据申请单号或发票记录 ID 读取
biz_invoice_record。 - 优先使用记录中的
platform_code、platform_account_code查询平台账号。 - 如果历史记录缺少平台字段,且系统中只有一个可用平台账号,可以走兼容兜底;如果存在多个候选账号,返回需要人工确认的业务异常。
- 按
platform_code取客户端并执行对应操作。 - 更新发票记录状态,不覆盖历史平台字段。
服务分层
新增后端结构:
controller/admin/invoiceplatform/
InvoicePlatformAccountController.java
vo/InvoicePlatformAccountCreateReqVO.java
vo/InvoicePlatformAccountUpdateReqVO.java
vo/InvoicePlatformAccountPageReqVO.java
vo/InvoicePlatformAccountRespVO.java
vo/InvoicePlatformAccountTestReqVO.java
vo/InvoicePlatformAccountTestRespVO.java
dal/dataobject/invoiceplatform/
InvoicePlatformAccountDO.java
dal/mysql/invoiceplatform/
InvoicePlatformAccountMapper.java
service/invoice/platform/config/
InvoicePlatformAccountConfig.java
InvoicePlatformSecretProvider.java
EnvAndPropertyInvoicePlatformSecretProvider.java
service/invoice/platform/routing/
InvoicePlatformAccountResolver.java
InvoicePlatformClientRegistry.java
InvoiceRouteContext.java
管理后台 API:
| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/business/invoice-platform-account/create |
创建平台账号。 |
PUT |
/business/invoice-platform-account/update |
更新平台账号,密钥只更新引用。 |
DELETE |
/business/invoice-platform-account/delete?id= |
删除平台账号,已有发票记录不变。 |
GET |
/business/invoice-platform-account/get?id= |
获取详情,返回 secret_ref,不返回密钥。 |
GET |
/business/invoice-platform-account/page |
分页查询。 |
POST |
/business/invoice-platform-account/test-connect |
使用账号配置发起平台连通性测试。 |
客户端注册
每个供应商客户端声明自己的平台编码:
public interface InvoicePlatformClient {
String platformCode();
ApplyResult apply(ApplyContext ctx);
QueryResult query(QueryContext ctx);
InvalidateResult invalidate(PostProcessContext ctx);
RedInkResult redInk(PostProcessContext ctx);
}
注册表在启动时收集所有客户端:
@Component
public class InvoicePlatformClientRegistry {
private final Map<String, InvoicePlatformClient> clients;
public InvoicePlatformClientRegistry(List<InvoicePlatformClient> candidates) {
this.clients = candidates.stream()
.collect(Collectors.toUnmodifiableMap(
client -> client.platformCode().toUpperCase(Locale.ROOT),
Function.identity()));
}
public InvoicePlatformClient getRequired(String platformCode) {
InvoicePlatformClient client = clients.get(platformCode.toUpperCase(Locale.ROOT));
if (client == null) {
throw exception(INVOICE_PLATFORM_CLIENT_NOT_EXISTS);
}
return client;
}
}
这样平台选择从“Spring 只注入一个全局 InvoicePlatformClient”升级为“按记录或业务配置路由到对应客户端”。
诺诺账号示例
联调账号在平台账号表中表现为:
| 字段 | 值 |
|---|---|
account_id |
对应水司账户 ID |
platform_code |
NUONUO |
platform_account_code |
NUONUO_STAGE_339901999999142 |
account_name |
诺诺测试账号 |
base_url |
https://cmp-stage.nntest.cn |
version |
1.0.0 |
app_id |
convert |
app_key_secret_ref |
env:NUONUO_STAGE_APP_KEY |
seller_taxnum |
339901999999142 |
真实密钥通过运行环境设置 NUONUO_STAGE_APP_KEY,不写入仓库、不写入数据库迁移脚本。
兼容策略
- 现有
biz_invoice.extra_properties保留,用于开票业务模板的扩展参数。 - 现有诺诺 YAML 配置可以保留为本地联调兜底,但生产调用优先使用平台账号表。
- 平台账号表上线后,新增发票记录必须写入
platform_code和platform_account_code。 - 历史记录缺少平台字段时,只允许在唯一候选账号场景自动兜底。
InvoiceApplyReqVO增加可选supplier字段,前端在多供应商场景必须传值。
测试策略
| 层级 | 覆盖点 |
|---|---|
| Mapper/Service 单测 | 平台账号创建、更新、禁用、唯一性、分页过滤。 |
| Secret Provider 单测 | env: 和 nacos: 引用解析,缺失密钥报错,日志不包含密钥值。 |
| Resolver 单测 | 单配置自动选择、多配置歧义报错、停用账号不可用、记录字段优先。 |
| Registry 单测 | 多客户端注册、重复平台编码启动失败、未知平台编码报错。 |
| InvoiceRecordService 单测 | 开票申请写入平台字段,查询/作废/冲红按历史记录路由。 |
| Nuonuo 合约测试 | 请求体使用账号表配置组装,查询接口兼容空数组、对象数组和失败响应。 |
| Stage Smoke | 使用 NUONUO_STAGE_APP_KEY 环境变量执行诺诺测试环境查询接口,默认跳过。 |
落地顺序
- 新增平台账号表、DO、Mapper、VO、Controller、Service。
- 增加
biz_invoice默认平台账号字段,并更新创建、更新、返回 VO。 - 新增密钥解析接口和运行时账号配置对象。
- 新增平台账号解析器和平台客户端注册表。
- 改造发票申请、查询、作废、冲红调用链。
- 把诺诺客户端从固定属性读取改为接收运行时账号配置。
- 增加单元测试、契约测试和默认跳过的联调 Smoke 测试。