docs: add invoice platform account config design
This commit is contained in:
parent
55b9e144ef
commit
fc42773948
@ -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.
|
||||
@ -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 测试。
|
||||
Loading…
x
Reference in New Issue
Block a user