# 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 { default InvoicePlatformAccountDO selectByRoute(Long accountId, String platformCode, String platformAccountCode) { return selectOne(InvoicePlatformAccountDO::getAccountId, accountId, InvoicePlatformAccountDO::getPlatformCode, platformCode, InvoicePlatformAccountDO::getPlatformAccountCode, platformAccountCode); } default PageResult selectPage(InvoicePlatformAccountPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() .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 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 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 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.