docs: add invoice platform account config design

This commit is contained in:
tangweijie 2026-07-22 15:23:52 +08:00
parent 55b9e144ef
commit fc42773948
2 changed files with 1087 additions and 0 deletions

View File

@ -0,0 +1,803 @@
# Invoice Platform Account Config Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a generic invoice platform account configuration layer so invoice business settings can route to Nuonuo now and to other suppliers later without storing platform secrets in the database.
**Architecture:** Keep business invoice rules in `biz_invoice`, add `biz_invoice_platform_account` for technical platform credentials and endpoint configuration, route platform calls through `InvoicePlatformAccountResolver` and `InvoicePlatformClientRegistry`, and pass a runtime `InvoicePlatformAccountConfig` into each platform client call.
**Tech Stack:** Spring Boot, MyBatis Plus, PostgreSQL DDL, existing `BaseMapperX`, existing `BeanUtils` and `JsonUtils`, JUnit 5, Mockito, optional Nuonuo stage smoke test gated by environment variables.
---
## Starting Point
Execute this plan in `water-backend`. It assumes the Nuonuo client work from branch `feat/nuonuo-stage-smoke` is present, because that work already adds:
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/InvoicePlatformClient.java`
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/mock/MockInvoicePlatformClient.java`
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/**`
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/**`
- `biz_invoice_record.platform_code`
- `biz_invoice_record.platform_account_code`
Do not commit real platform keys. Nuonuo stage smoke uses `NUONUO_STAGE_APP_KEY` from the process environment.
## Task 1: Add Data Model and Error Codes
**Files:**
- Create `sql/rev005/REV005_invoice_platform_account_config.sql`
- Modify `sw-business/sw-business-api/src/main/java/cn/com/emsoft/sw/business/enums/ErrorCodeConstants.java`
- Create `sw-business/sw-business-api/src/main/java/cn/com/emsoft/sw/business/enums/invoice/InvoicePlatformCodeEnum.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/invoiceplatform/InvoicePlatformAccountDO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/invoiceplatform/InvoicePlatformAccountMapper.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/invoice/InvoiceDO.java`
- Modify `sql/rev005/REV005_invoice_base_tables_ddl.sql`
- [ ] Add failing compile target by referencing the new DO and mapper from a new service test stub created in Task 2. Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest=InvoicePlatformAccountServiceImplTest test
```
Expected result before implementation: test compilation fails because the classes do not exist.
- [ ] Add invoice platform error codes after `INVOICE_SYS008_ERROR`:
```java
ErrorCode INVOICE_PLATFORM_ACCOUNT_NOT_EXISTS = new ErrorCode(1_025_030_031, "发票平台账号不存在");
ErrorCode INVOICE_PLATFORM_ACCOUNT_DUPLICATE = new ErrorCode(1_025_030_032, "发票平台账号编码重复");
ErrorCode INVOICE_PLATFORM_ACCOUNT_DISABLED = new ErrorCode(1_025_030_033, "发票平台账号已停用");
ErrorCode INVOICE_PLATFORM_ROUTE_MISSING = new ErrorCode(1_025_030_034, "缺少发票平台路由配置");
ErrorCode INVOICE_PLATFORM_ROUTE_AMBIGUOUS = new ErrorCode(1_025_030_035, "存在多个开票配置,请指定供应商");
ErrorCode INVOICE_PLATFORM_CLIENT_NOT_EXISTS = new ErrorCode(1_025_030_036, "发票平台客户端不存在");
ErrorCode INVOICE_PLATFORM_SECRET_MISSING = new ErrorCode(1_025_030_037, "发票平台密钥未配置");
ErrorCode INVOICE_PLATFORM_CONNECT_FAILED = new ErrorCode(1_025_030_038, "发票平台连通性测试失败");
```
- [ ] Add platform enum:
```java
package cn.com.emsoft.sw.business.enums.invoice;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum InvoicePlatformCodeEnum {
MOCK("MOCK", "模拟平台"),
NUONUO("NUONUO", "诺诺"),
FANGXIN("FANGXIN", "方欣"),
BAIWANG("BAIWANG", "百望"),
HANGXIN("HANGXIN", "航信"),
YONGYOU("YONGYOU", "用友");
private final String code;
private final String description;
}
```
- [ ] Add DDL:
```sql
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;
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;
```
- [ ] Add DO:
```java
@TableName("biz_invoice_platform_account")
@KeySequence("biz_invoice_platform_account_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
@GetterRef
public class InvoicePlatformAccountDO extends TenantBaseDO {
@TableId
private Long id;
private Long accountId;
private String platformCode;
private String platformAccountCode;
private String accountName;
private String baseUrl;
private String version;
private String appId;
private String appKeySecretRef;
private String sellerTaxnum;
private String companyCode;
private String callbackUrl;
private String defaultBuyerEmail;
private String defaultBuyerMobile;
private String extraProperties;
private Short status;
}
```
- [ ] Add mapper methods:
```java
@Mapper
public interface InvoicePlatformAccountMapper extends BaseMapperX<InvoicePlatformAccountDO> {
default InvoicePlatformAccountDO selectByRoute(Long accountId, String platformCode, String platformAccountCode) {
return selectOne(InvoicePlatformAccountDO::getAccountId, accountId,
InvoicePlatformAccountDO::getPlatformCode, platformCode,
InvoicePlatformAccountDO::getPlatformAccountCode, platformAccountCode);
}
default PageResult<InvoicePlatformAccountDO> selectPage(InvoicePlatformAccountPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<InvoicePlatformAccountDO>()
.eqIfPresent(InvoicePlatformAccountDO::getAccountId, reqVO.getAccountId())
.eqIfPresent(InvoicePlatformAccountDO::getPlatformCode, reqVO.getPlatformCode())
.eqIfPresent(InvoicePlatformAccountDO::getStatus, reqVO.getStatus())
.orderByDesc(InvoicePlatformAccountDO::getUpdateTime));
}
}
```
- [ ] Add `platformCode` and `platformAccountCode` fields to `InvoiceDO`.
- [ ] Verify compile for API and server modules:
```bash
mvn -pl sw-business/sw-business-server -am -DskipTests compile
```
## Task 2: Add Platform Account Admin API
**Files:**
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/InvoicePlatformAccountController.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountBaseVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountCreateReqVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountUpdateReqVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountPageReqVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountRespVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountTestReqVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/vo/InvoicePlatformAccountTestRespVO.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoiceplatform/InvoicePlatformAccountService.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoiceplatform/InvoicePlatformAccountServiceImpl.java`
- Create `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoiceplatform/InvoicePlatformAccountServiceImplTest.java`
- [ ] Write failing service tests first:
```java
@ExtendWith(MockitoExtension.class)
class InvoicePlatformAccountServiceImplTest {
@Mock
private InvoicePlatformAccountMapper accountMapper;
@Mock
private CompanyAccountService companyAccountService;
@InjectMocks
private InvoicePlatformAccountServiceImpl service;
@Test
void createAccount_shouldRejectDuplicateRoute() {
InvoicePlatformAccountCreateReqVO req = new InvoicePlatformAccountCreateReqVO();
req.setAccountId(3001L);
req.setPlatformCode("NUONUO");
req.setPlatformAccountCode("NUONUO_STAGE_339901999999142");
req.setAccountName("诺诺测试账号");
req.setBaseUrl("https://cmp-stage.nntest.cn");
req.setAppId("convert");
req.setAppKeySecretRef("env:NUONUO_STAGE_APP_KEY");
req.setSellerTaxnum("339901999999142");
req.setStatus((short) 1);
when(companyAccountService.getCompanyAccount(3001L)).thenReturn(new CompanyAccountDO());
when(accountMapper.selectByRoute(3001L, "NUONUO", "NUONUO_STAGE_339901999999142"))
.thenReturn(InvoicePlatformAccountDO.builder().id(1L).build());
assertThrows(ServiceException.class, () -> service.createAccount(req));
}
}
```
- [ ] Add base VO with validation:
```java
@Data
public class InvoicePlatformAccountBaseVO {
@NotNull
private Long accountId;
@NotBlank
private String platformCode;
@NotBlank
private String platformAccountCode;
@NotBlank
private String accountName;
@NotBlank
private String baseUrl;
private String version;
@NotBlank
private String appId;
@NotBlank
private String appKeySecretRef;
@NotBlank
private String sellerTaxnum;
private String companyCode;
private String callbackUrl;
private String defaultBuyerEmail;
private String defaultBuyerMobile;
private Map<String, Object> extraProperties;
@NotNull
private Short status;
}
```
- [ ] Implement service methods:
```java
Long createAccount(InvoicePlatformAccountCreateReqVO reqVO);
void updateAccount(InvoicePlatformAccountUpdateReqVO reqVO);
void deleteAccount(Long id);
InvoicePlatformAccountDO getAccount(Long id);
PageResult<InvoicePlatformAccountDO> getAccountPage(InvoicePlatformAccountPageReqVO pageReqVO);
InvoicePlatformAccountDO getEnabledByRoute(Long accountId, String platformCode, String platformAccountCode);
```
- [ ] Serialize `extraProperties` with existing `JsonUtils.toJsonString` before insert or update, and parse it back in response conversion.
- [ ] Implement controller endpoints:
```text
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=
GET /business/invoice-platform-account/page
POST /business/invoice-platform-account/test-connect
```
- [ ] Use permissions:
```text
business:invoice-platform-account:create
business:invoice-platform-account:update
business:invoice-platform-account:delete
business:invoice-platform-account:query
business:invoice-platform-account:test-connect
```
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest=InvoicePlatformAccountServiceImplTest test
```
## Task 3: Add Runtime Config and Secret Resolution
**Files:**
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/config/InvoicePlatformAccountConfig.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/config/InvoicePlatformSecretProvider.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/config/EnvAndPropertyInvoicePlatformSecretProvider.java`
- Create `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/config/EnvAndPropertyInvoicePlatformSecretProviderTest.java`
- [ ] Write failing tests for secret resolution:
```java
@Test
void resolve_shouldReadSpringPropertyForNacosReference() {
MockEnvironment environment = new MockEnvironment()
.withProperty("invoice.nuonuo.stage.app-key", "0123456789abcdef");
EnvAndPropertyInvoicePlatformSecretProvider provider =
new EnvAndPropertyInvoicePlatformSecretProvider(environment);
assertEquals("0123456789abcdef", provider.resolve("nacos:invoice.nuonuo.stage.app-key"));
}
@Test
void resolve_shouldRejectMissingSecret() {
EnvAndPropertyInvoicePlatformSecretProvider provider =
new EnvAndPropertyInvoicePlatformSecretProvider(new MockEnvironment());
assertThrows(ServiceException.class, () -> provider.resolve("nacos:invoice.missing"));
}
```
- [ ] Add runtime config record:
```java
public record InvoicePlatformAccountConfig(
Long id,
Long accountId,
String platformCode,
String platformAccountCode,
String accountName,
String baseUrl,
String version,
String appId,
String appKey,
String sellerTaxnum,
String companyCode,
String callbackUrl,
String defaultBuyerEmail,
String defaultBuyerMobile,
Map<String, Object> extraProperties
) {}
```
- [ ] Implement provider behavior:
```java
@Component
public class EnvAndPropertyInvoicePlatformSecretProvider implements InvoicePlatformSecretProvider {
private final Environment environment;
public String resolve(String secretRef) {
String value;
if (secretRef.startsWith("env:")) {
value = System.getenv(secretRef.substring("env:".length()));
} else if (secretRef.startsWith("nacos:")) {
value = environment.getProperty(secretRef.substring("nacos:".length()));
} else {
value = environment.getProperty(secretRef);
}
if (StrUtil.isBlank(value)) {
throw exception(INVOICE_PLATFORM_SECRET_MISSING);
}
return value;
}
}
```
- [ ] Verify:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest=EnvAndPropertyInvoicePlatformSecretProviderTest test
```
## Task 4: Add Resolver and Client Registry
**Files:**
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/routing/InvoicePlatformClientRegistry.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/routing/InvoicePlatformAccountResolver.java`
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/routing/InvoiceRouteContext.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/InvoicePlatformClient.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/mock/MockInvoicePlatformClient.java`
- Create `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/routing/InvoicePlatformClientRegistryTest.java`
- Create `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/routing/InvoicePlatformAccountResolverTest.java`
- [ ] Write failing registry tests:
```java
@Test
void getRequired_shouldReturnClientByUppercasePlatformCode() {
InvoicePlatformClient client = mock(InvoicePlatformClient.class);
when(client.platformCode()).thenReturn("NUONUO");
InvoicePlatformClientRegistry registry = new InvoicePlatformClientRegistry(List.of(client));
assertSame(client, registry.getRequired("nuonuo"));
}
@Test
void constructor_shouldRejectDuplicatePlatformCode() {
InvoicePlatformClient left = mock(InvoicePlatformClient.class);
InvoicePlatformClient right = mock(InvoicePlatformClient.class);
when(left.platformCode()).thenReturn("NUONUO");
when(right.platformCode()).thenReturn("nuonuo");
assertThrows(ServiceException.class, () -> new InvoicePlatformClientRegistry(List.of(left, right)));
}
```
- [ ] Extend client contract:
```java
public interface InvoicePlatformClient {
String platformCode();
ApplyResult apply(ApplyContext ctx);
QueryResult query(QueryContext ctx);
InvalidateResult invalidate(PostProcessContext ctx);
RedInkResult redInk(PostProcessContext ctx);
record ApplyContext(
String applicationNo,
Long accountId,
String invoiceTitle,
String taxNo,
String invoiceType,
BigDecimal totalAmount,
String email,
String mobile,
InvoicePlatformAccountConfig platformAccount
) {}
}
```
Apply the same `InvoicePlatformAccountConfig platformAccount` addition to `QueryContext` and `PostProcessContext`.
- [ ] Implement `InvoicePlatformAccountResolver` methods:
```java
InvoiceRouteContext resolveForApply(Long accountId, Short invoiceType, Integer supplier);
InvoicePlatformAccountConfig resolveByRecord(Long accountId, String platformCode, String platformAccountCode);
InvoicePlatformAccountConfig resolveByAccount(InvoicePlatformAccountDO accountDO);
```
- [ ] Resolver rules:
```text
resolveForApply:
1. Query enabled biz_invoice rows by accountId and invoiceType.
2. If supplier is present, filter by supplier.
3. If no row remains, throw INVOICE_PLATFORM_ROUTE_MISSING.
4. If more than one row remains, throw INVOICE_PLATFORM_ROUTE_AMBIGUOUS.
5. Read enabled platform account by platform_code and platform_account_code.
6. Resolve appKey through InvoicePlatformSecretProvider.
7. Return InvoiceRouteContext with business config and runtime account config.
resolveByRecord:
1. Require platform_code and platform_account_code from biz_invoice_record.
2. Read enabled platform account by route.
3. Resolve appKey through InvoicePlatformSecretProvider.
```
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest='InvoicePlatformClientRegistryTest,InvoicePlatformAccountResolverTest' test
```
## Task 5: Bind Business Invoice Config to Platform Account
**Files:**
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoice/vo/InvoiceCreateReqVO.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoice/vo/InvoiceSaveReqVO.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoice/vo/InvoiceRespVO.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoice/vo/InvoicePageRespVO.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/InvoiceServiceImpl.java`
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/InvoiceServiceImplTest.java` or create it if the file does not exist.
- [ ] Add fields to create/update/response VO:
```java
@Schema(description = "发票平台编码,例如 NUONUO")
private String platformCode;
@Schema(description = "发票平台账号编码")
private String platformAccountCode;
```
- [ ] In `InvoiceServiceImpl.createInvoice` and `updateInvoice`, validate route when either platform field is present:
```java
if (StrUtil.isNotBlank(reqVO.getPlatformCode()) || StrUtil.isNotBlank(reqVO.getPlatformAccountCode())) {
invoicePlatformAccountService.getEnabledByRoute(
reqVO.getAccountId(), reqVO.getPlatformCode(), reqVO.getPlatformAccountCode());
}
```
- [ ] Require both route fields together. If only one is present, throw `INVOICE_PLATFORM_ROUTE_MISSING`.
- [ ] Keep the existing upsert key `accountId + supplier`, because it preserves the current management API behavior.
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest=InvoiceServiceImplTest test
```
## Task 6: Route Invoice Lifecycle Through Platform Account
**Files:**
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoice/vo/InvoiceApplyReqVO.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/InvoiceRecordServiceImpl.java`
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/InvoiceRecordServiceImplTest.java`
- [ ] Add optional supplier to invoice apply request:
```java
@Schema(description = "供应商模板编码,存在多个开票配置时必填")
private Integer supplier;
```
- [ ] Replace direct `InvoicePlatformClient platformClient` injection with:
```java
@Resource
private InvoicePlatformAccountResolver invoicePlatformAccountResolver;
@Resource
private InvoicePlatformClientRegistry invoicePlatformClientRegistry;
```
- [ ] Update apply flow:
```java
InvoiceRouteContext route = invoicePlatformAccountResolver.resolveForApply(
accountId, reqVO.getInvoiceType(), reqVO.getSupplier());
InvoicePlatformClient client = invoicePlatformClientRegistry.getRequired(
route.platformAccount().platformCode());
ApplyResult platformResult = client.apply(new ApplyContext(
applicationNo,
accountId,
reqVO.getInvoiceTitle(),
reqVO.getTaxNo(),
String.valueOf(reqVO.getInvoiceType()),
totalAmount,
reqVO.getEmail(),
reqVO.getMobile(),
route.platformAccount()));
```
- [ ] Persist `route.platformAccount().platformCode()` and `route.platformAccount().platformAccountCode()` to `InvoiceRecordDO` before returning the apply response.
- [ ] Update query, invalidate, and red-ink flow to resolve platform account from the invoice record:
```java
InvoicePlatformAccountConfig account = invoicePlatformAccountResolver.resolveByRecord(
record.getAccountId(), record.getPlatformCode(), record.getPlatformAccountCode());
InvoicePlatformClient client = invoicePlatformClientRegistry.getRequired(account.platformCode());
```
- [ ] Add tests:
```text
applyInvoice_shouldWritePlatformRouteToRecord
applyInvoice_shouldRejectAmbiguousSupplierRoute
queryInvoice_shouldUseRecordPlatformRoute
invalidateInvoice_shouldUseRecordPlatformRoute
redInkInvoice_shouldUseRecordPlatformRoute
```
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest=InvoiceRecordServiceImplTest test
```
## Task 7: Adapt Nuonuo Client to Runtime Account Config
**Files:**
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/NuonuoInvoicePlatformClient.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/NuonuoTransportClient.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/NuonuoInvoiceProperties.java`
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/NuonuoInvoicePlatformClientTest.java`
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/NuonuoTransportClientTest.java`
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoice/platform/nuonuo/NuonuoStageSmokeTest.java`
- [ ] Add `platformCode()`:
```java
@Override
public String platformCode() {
return "NUONUO";
}
```
- [ ] Stop choosing Nuonuo account from static account map in normal calls. Use `ctx.platformAccount()`:
```java
InvoicePlatformAccountConfig account = ctx.platformAccount();
NuonuoEnvelope envelope = NuonuoEnvelope.of(
account.version(),
account.appId(),
requestIdGenerator.next(),
cryptoService.encrypt(payloadJson, account.appKey()));
NuonuoEnvelopeResponse response = transportClient.post(account.baseUrl(), endpoint, envelope);
```
- [ ] Keep `NuonuoInvoiceProperties` only for stage smoke bootstrap when the test constructs an in-memory `InvoicePlatformAccountConfig`.
- [ ] Update client tests so they assert:
```text
baseUrl comes from InvoicePlatformAccountConfig
appId comes from InvoicePlatformAccountConfig
appKey comes from InvoicePlatformAccountConfig
sellerTaxnum comes from InvoicePlatformAccountConfig
platformCode result remains NUONUO
```
- [ ] Update `NuonuoStageSmokeTest` to create runtime config:
```java
private InvoicePlatformAccountConfig stageAccount() {
return new InvoicePlatformAccountConfig(
1L,
1L,
"NUONUO",
"NUONUO_STAGE_339901999999142",
"诺诺测试账号",
"https://cmp-stage.nntest.cn",
"1.0.0",
"convert",
System.getenv("NUONUO_STAGE_APP_KEY"),
"339901999999142",
null,
null,
null,
null,
Map.of());
}
```
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest='NuonuoInvoicePlatformClientTest,NuonuoTransportClientTest,NuonuoResponseMapperTest,NuonuoStageSmokeTest' test
```
Expected: `NuonuoStageSmokeTest` is skipped unless `NUONUO_STAGE_SMOKE_ENABLED=true`.
## Task 8: Add Platform Account Test-Connect Operation
**Files:**
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoiceplatform/InvoicePlatformAccountService.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/invoiceplatform/InvoicePlatformAccountServiceImpl.java`
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/InvoicePlatformAccountController.java`
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/invoiceplatform/InvoicePlatformAccountServiceImplTest.java`
- [ ] Add service method:
```java
InvoicePlatformAccountTestRespVO testConnect(InvoicePlatformAccountTestReqVO reqVO);
```
- [ ] Implement by resolving account config and calling the platform client's query endpoint with a harmless nonexistent `applicationNo`:
```java
InvoicePlatformAccountConfig account = resolveRuntimeConfig(accountDO);
InvoicePlatformClient client = invoicePlatformClientRegistry.getRequired(account.platformCode());
QueryResult result = client.query(new QueryContext(
null,
account.accountId(),
"CONNECT-" + System.currentTimeMillis(),
account.platformCode(),
account.platformAccountCode(),
account));
return new InvoicePlatformAccountTestRespVO(result.success(), result.errorMsg());
```
- [ ] Treat successful transport with empty query result as connected. For Nuonuo stage, decrypted `[]` is a valid connected response.
- [ ] Add tests:
```text
testConnect_shouldReturnSuccessForEmptyNuonuoQuery
testConnect_shouldReturnFailureWhenClientThrowsException
testConnect_shouldNotExposeResolvedAppKey
```
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest=InvoicePlatformAccountServiceImplTest test
```
## Task 9: Controller Contract and Integration Coverage
**Files:**
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/invoice/InvoiceControllerContractTest.java`
- Create `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/invoiceplatform/InvoicePlatformAccountControllerContractTest.java`
- [ ] Add contract tests for new endpoints:
```text
POST /business/invoice-platform-account/create validates required fields
GET /business/invoice-platform-account/page exposes platformCode and masks secret value
POST /business/invoice-platform-account/test-connect returns connected flag and message
POST /business/invoice/apply accepts optional supplier
```
- [ ] Ensure response VO exposes `appKeySecretRef` but never exposes `appKey`.
- [ ] Run:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest='InvoiceControllerContractTest,InvoicePlatformAccountControllerContractTest' test
```
## Task 10: Full Verification
**Files:**
- No source files beyond previous tasks.
- [ ] Run focused unit and contract tests:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest='InvoicePlatformAccountServiceImplTest,InvoicePlatformClientRegistryTest,InvoicePlatformAccountResolverTest,EnvAndPropertyInvoicePlatformSecretProviderTest,InvoiceRecordServiceImplTest,InvoiceServiceImplTest,NuonuoInvoicePlatformClientTest,NuonuoTransportClientTest,NuonuoResponseMapperTest,InvoiceControllerContractTest,InvoicePlatformAccountControllerContractTest' test
```
- [ ] Run existing invoice regression:
```bash
mvn -pl sw-business/sw-business-server -am -Dtest='InvoiceRecordServiceImplTest,Nuonuo*Test,InvoiceControllerContractTest' test
```
- [ ] Run compile for all affected modules:
```bash
mvn -pl sw-business/sw-business-server -am -DskipTests compile
```
- [ ] Run optional Nuonuo stage smoke:
Before running this command, export `NUONUO_STAGE_APP_KEY` in the shell from the approved secret source.
```bash
NUONUO_STAGE_SMOKE_ENABLED=true \
mvn -pl sw-business/sw-business-server -am -Dtest=NuonuoStageSmokeTest test
```
- [ ] Verify the real key is not in tracked files:
```bash
rg -n "happy\\^_\\^everyday" -S .
```
Expected: no matches.
- [ ] Check whitespace:
```bash
git diff --check
```
## Commit Guidance
- Commit implementation after Task 10 passes.
- Suggested backend commit message:
```text
feat(invoice): add platform account routing config
```
- Keep the Nuonuo stage key out of Git. Use `NUONUO_STAGE_APP_KEY` locally or platform-managed secret injection in deployed environments.

View File

@ -0,0 +1,284 @@
# 发票平台账号配置设计
## 背景
当前发票模块已经有三类配置和业务数据:
- `biz_invoice`:开票业务配置,按水司账户维护供应商、开票限额、开票类型、自动开票、扩展参数。
- `biz_invoice_taxrate`:税收分类编码、税率、规格单位等明细配置。
- `biz_cust_invoice`:客户发票抬头、税号、开户行、地址电话等客户资料。
这几类配置可以支撑“能不能开票、怎么组成发票明细、给谁开票”但没有独立维护“对接哪个发票平台账号、平台地址、APPID、销方税号、回调地址、密钥引用”的能力。诺诺联调已经证明接口链路可通但当前实现仍偏向代码或环境变量配置不适合运营后台长期维护也不适合未来接入方欣、百望、航信、用友等其他供应商。
## 目标
1. 把发票平台账号做成通用配置,不绑定诺诺专有表结构。
2. 允许一个水司账户配置多个发票平台账号,并由 `biz_invoice` 业务配置选择默认账号。
3. 发票申请、查询、作废、冲红都能回溯当时使用的平台账号,避免后续配置变更影响已开票记录。
4. 密钥不落业务库,只在业务库保存 `secret_ref`实际密钥从环境变量、Nacos、KMS 或 Vault 解析。
5. 保持现有 `/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
```sql
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`
在现有开票业务配置表增加默认平台账号绑定:
```sql
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`,不保存真实密钥。后端增加统一解析接口:
```java
public interface InvoicePlatformSecretProvider {
String resolve(String secretRef);
}
```
第一阶段支持两种引用:
| 引用格式 | 解析来源 | 用途 |
| --- | --- | --- |
| `env:NUONUO_STAGE_APP_KEY` | 系统环境变量 | 本地联调、流水线、临时测试。 |
| `nacos:invoice.nuonuo.stage.app-key` | 应用配置中心属性 | 测试环境、生产环境。 |
后续接入 KMS 或 Vault 时扩展该接口,不影响业务表结构和平台账号表结构。日志中禁止打印 `appKey`、解密后的密钥、`secret_ref` 解析结果。
## 路由流程
### 开票申请
1. 根据 `chargeIds` 校验费用归属,并解析水司账户 `accountId`
2. 读取 `biz_invoice` 中启用的业务配置,匹配 `accountId``invoiceType`,并优先使用请求中的 `supplier`
3. 如果请求没有传 `supplier`,且同一 `accountId + invoiceType` 只有一条启用配置,则使用该配置。
4. 如果请求没有传 `supplier`,且存在多条启用配置,返回明确业务异常,要求前端传供应商模板。
5. 使用业务配置中的 `platform_code``platform_account_code` 查询启用的平台账号。
6. 从 `InvoicePlatformClientRegistry``platform_code` 获取客户端。
7. 调用平台客户端,写入 `biz_invoice_record.platform_code``biz_invoice_record.platform_account_code``sys_request_no` 和平台返回状态。
### 查询、作废、冲红
1. 根据申请单号或发票记录 ID 读取 `biz_invoice_record`
2. 优先使用记录中的 `platform_code``platform_account_code` 查询平台账号。
3. 如果历史记录缺少平台字段,且系统中只有一个可用平台账号,可以走兼容兜底;如果存在多个候选账号,返回需要人工确认的业务异常。
4. 按 `platform_code` 取客户端并执行对应操作。
5. 更新发票记录状态,不覆盖历史平台字段。
## 服务分层
新增后端结构:
```text
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` | 使用账号配置发起平台连通性测试。 |
## 客户端注册
每个供应商客户端声明自己的平台编码:
```java
public interface InvoicePlatformClient {
String platformCode();
ApplyResult apply(ApplyContext ctx);
QueryResult query(QueryContext ctx);
InvalidateResult invalidate(PostProcessContext ctx);
RedInkResult redInk(PostProcessContext ctx);
}
```
注册表在启动时收集所有客户端:
```java
@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`,不写入仓库、不写入数据库迁移脚本。
## 兼容策略
1. 现有 `biz_invoice.extra_properties` 保留,用于开票业务模板的扩展参数。
2. 现有诺诺 YAML 配置可以保留为本地联调兜底,但生产调用优先使用平台账号表。
3. 平台账号表上线后,新增发票记录必须写入 `platform_code``platform_account_code`
4. 历史记录缺少平台字段时,只允许在唯一候选账号场景自动兜底。
5. `InvoiceApplyReqVO` 增加可选 `supplier` 字段,前端在多供应商场景必须传值。
## 测试策略
| 层级 | 覆盖点 |
| --- | --- |
| Mapper/Service 单测 | 平台账号创建、更新、禁用、唯一性、分页过滤。 |
| Secret Provider 单测 | `env:``nacos:` 引用解析,缺失密钥报错,日志不包含密钥值。 |
| Resolver 单测 | 单配置自动选择、多配置歧义报错、停用账号不可用、记录字段优先。 |
| Registry 单测 | 多客户端注册、重复平台编码启动失败、未知平台编码报错。 |
| InvoiceRecordService 单测 | 开票申请写入平台字段,查询/作废/冲红按历史记录路由。 |
| Nuonuo 合约测试 | 请求体使用账号表配置组装,查询接口兼容空数组、对象数组和失败响应。 |
| Stage Smoke | 使用 `NUONUO_STAGE_APP_KEY` 环境变量执行诺诺测试环境查询接口,默认跳过。 |
## 落地顺序
1. 新增平台账号表、DO、Mapper、VO、Controller、Service。
2. 增加 `biz_invoice` 默认平台账号字段,并更新创建、更新、返回 VO。
3. 新增密钥解析接口和运行时账号配置对象。
4. 新增平台账号解析器和平台客户端注册表。
5. 改造发票申请、查询、作废、冲红调用链。
6. 把诺诺客户端从固定属性读取改为接收运行时账号配置。
7. 增加单元测试、契约测试和默认跳过的联调 Smoke 测试。