diff --git a/docs/superpowers/plans/2026-07-22-invoice-platform-account-config.md b/docs/superpowers/plans/2026-07-22-invoice-platform-account-config.md new file mode 100644 index 0000000..5cde1ff --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-invoice-platform-account-config.md @@ -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 { + + 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. diff --git a/docs/superpowers/specs/2026-07-22-invoice-platform-account-config-design.md b/docs/superpowers/specs/2026-07-22-invoice-platform-account-config-design.md new file mode 100644 index 0000000..06849ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-invoice-platform-account-config-design.md @@ -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 clients; + + public InvoicePlatformClientRegistry(List 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 测试。