feat(p1-market): market 写路径整改——member 暴露写端口,消除 market→member.dal BC 违例(BC 门全绿)
market 5 类(MarketAccountProjectionProvider/AdminMarketReviewServiceImpl/MarketInstallServiceImpl/ MarketLicenseServiceImpl/MarketPublishServiceImpl)此前直接构建 member.dal 的 AccountRecordProjectionDO 并经 AccountRecordProjectionMapper upsert 进 member 表——跨 BC 写路径违例(原 BcBoundaryArchTest 单点豁免登记)。 本次整改(ultracode 工作流:设计→实现→对抗验证;JDK21 scoped 实跑验证): - member-api 新增对外写端口 MuseAccountRecordProjectionApi + MuseAccountRecordProjectionSaveReqDTO; member-server MuseAccountRecordProjectionApiImpl 实现,读写 member 自有 DAL,整体平移原 upsert 语义 (insert/update 分支、update rows!=1 抛 IllegalStateException 防伪成功)。 - 安全边界:DTO 刻意不含 tenantId,由实现侧从 TenantContextHolder 注入,杜绝调用方(他域)伪造租户。 - 事务红线:写端口为进程内 Bean、不加 @Transactional,沿用调用方(market 的 REQUIRES_NEW)事务上下文, market 业务回滚则投影一并回滚,原子性与原实现等价。 - market 5 类改消费写端口 + DTO 替代 DO,移除 member.dal 代码依赖; BcBoundaryArchTest.KNOWN_VIOLATION_EXEMPTIONS 清空 → 通用 BC 门全绿(0 Architecture Violation)。 - 测试:insert/update/租户隔离语义随实现迁移至 MuseAccountRecordProjectionApiImplTest(4/0F); provider 测试改 mock 写端口、保留"失败→写 blocked outbox→上抛 UNAVAILABLE"语义(5/0F); market 其余 4 类测试同步(AdminReview 16 / Publish 14 / License 9 / Install 5)。 附带修复 round-2 一处假绿:round-2 把 ContentKnowledgeWorkOwnerFacade 重构为消费 MuseContentWorkOwnerApi 后, 旧测试 KnowledgeWorkOwnerFacadeTest 仍断言旧 WorkMapper 行为(当时验证构建在平台 QiniuSmsClientTest 时区用例处中止、未真正跑到 knowledge 模块,故漏网=假绿)。删除该旧测试,其装配守卫 (@ConditionalOnBean 值应为 MuseContentWorkOwnerApi)与 Unavailable 兜底失败关闭两用例并入 ContentKnowledgeWorkOwnerFacadeTest(3→5/0F),覆盖不丢。 验证(JDK21;-Dtest scoped 避开预存红 + muse-server -am):BUILD SUCCESS,日志无任何 <<< FAILURE/ERROR; BcBoundaryArchTest 1/0F 且 0 Architecture Violation、AgentsInfraIntegrity 3/0F、ContractFirst 2/0F、 P1rApiCoverage 7/0F、member 4/0F、knowledge 5/0F、content 端口 7/0F、market 5 类全绿,全 reactor 模块 SUCCESS。 注:本仓存在预存红测试(非本轮引入,启用真实测试 + CI 接电后将暴露,已登记 总账/AGENTS 后续): MuseAiTaskServiceTest 桩 eventPublishOutboxService 缺失致 11 例 NPE、MuseAiEventPublishOutboxMapperTest 需真实 PostgreSQL、平台 QiniuSmsClientTest 硬编码北京时区在非 +8 机器失败。故全量 reactor / CI-on-main 当前仍会因这些预存红呈 RED——本轮只声明 market 整改切片与 BC/契约/loop/覆盖门全绿,不声称全仓全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2cff86b808
commit
d38260fc51
@ -33,14 +33,17 @@
|
||||
|---|---|---|---|
|
||||
| `module.ai.application.muse.facade.ContentMuseWorkOwnerFacade` | 直连 content `Work/Chapter/Block` Mapper+DO(字节码 31 处) | content 暴露只读端口 `MuseContentWorkOwnerApi`(content-api 定义、content-server 实现读自有 DAL);AI 适配器改消费该端口 | 反向删豁免+旧码→红报 31 例;正向整改→绿,适配器 13/0F、端口实现 7/0F |
|
||||
| `module.knowledge.application.muse.facade.ContentKnowledgeWorkOwnerFacade` | 直连 content `WorkMapper/WorkDO`(与 AI 同构) | 复用同一 `MuseContentWorkOwnerApi.getActiveOwnedWorkRevision` 端口,移除 content.dal 依赖 | 通用门禁绿(0 Architecture Violation)、新增适配器单测 3/0F |
|
||||
| `module.market.application.muse.{MarketAccountProjectionProvider, AdminMarketReviewServiceImpl, MarketInstallServiceImpl, MarketLicenseServiceImpl, MarketPublishServiceImpl}`(5 类) | **写路径**:构建 `member.dal.AccountRecordProjectionDO`、经 `AccountRecordProjectionMapper` upsert 进 member 表 | member 暴露**写端口** `MuseAccountRecordProjectionApi.upsertRecordProjection(DTO)`(member-api 定义、member-server 实现读写自有 DAL,整体平移原 upsert 语义且事务上下文沿用调用方);market 5 类改消费端口 + `MuseAccountRecordProjectionSaveReqDTO` 替代 DO,移除 member.dal 依赖 | 反向:删豁免 5 类即门禁收紧;正向整改→通用门禁绿(0 Architecture Violation),新增 member 端口实现单测 4/0F、market provider 测试迁移为 mock 写端口 |
|
||||
|
||||
### 活跃登记(待整改,单点豁免)
|
||||
| 违例类 | 内容 | 处置 | 整改方向 |
|
||||
|---|---|---|---|
|
||||
| `module.market.application.muse.{MarketAccountProjectionProvider, AdminMarketReviewServiceImpl, MarketInstallServiceImpl, MarketLicenseServiceImpl, MarketPublishServiceImpl}`(5 类) | **写路径**:构建 `member.dal.AccountRecordProjectionDO`、经 `AccountRecordProjectionMapper` upsert 进 member 表 | `BcBoundaryArchTest.KNOWN_VIOLATION_EXEMPTIONS` 单点豁免这 5 类(显式可见) | member 暴露**写端口**(如 `MuseAccountRecordProjectionApi.upsert(DTO)`)+ member-server 接管 upsert + DTO 替代 DO;market 改消费端口、移除 member.dal 依赖后从豁免删除 → 门禁自动收紧。属写路径跨 BC 重构,作专项 |
|
||||
| (空) | —— | —— | 当前无活跃跨域 DAL 违例;`BcBoundaryArchTest.KNOWN_VIOLATION_EXEMPTIONS` 已清空 |
|
||||
|
||||
> 豁免一律**临时、显式、可见**(测试源码点名)。违例消除后必须从 `KNOWN_VIOLATION_EXEMPTIONS` 删除该类——**收紧即整改验收**。新违例不在豁免内,一律红。
|
||||
|
||||
> 遗留(更深一层 BC 收口,不在本次范围):market-server 的 pom 仍依赖 `muse-module-member-server`(可见 member 全部 internal);本次只消除 member.dal 的**代码 import**(ArchUnit 校验 import、不校验 pom 坐标),pom 坐标收口可登记后续。
|
||||
|
||||
---
|
||||
|
||||
## 四、扩展方式
|
||||
|
||||
@ -90,10 +90,10 @@ oh-my-muse/
|
||||
|
||||
- ✅ 机械门禁地基:CI 真跑测试(JDK21、触发分支已修为 `main`——此前误配 `master` 致 CI 从不运行)、门禁去硬编码(含分域计数派生化)、P0 冻结令。
|
||||
- ✅ 入口与中枢骨架:本文件 + `.agents/README.md` + 脊柱规则。
|
||||
- ✅ BC 边界 ArchUnit 门([`bc-boundaries`](.agents/rules/bc-boundaries.md)):**通用覆盖全业务 BC 间方向**;AI/knowledge 直连 content 违例已整改消除;**market→member.dal 写路径违例单点登记待整改**(故非"全绿",见该规则 §三)。
|
||||
- ✅ BC 边界 ArchUnit 门([`bc-boundaries`](.agents/rules/bc-boundaries.md)):**通用覆盖全业务 BC 间方向**;AI/knowledge 直连 content、market 直写 member 三处违例**均已整改消除**,豁免清单 `KNOWN_VIOLATION_EXEMPTIONS` **清空 → 全绿**(0 Architecture Violation;遗留:market-server pom 仍依赖 member-server,仅消除了 .dal 代码 import,见该规则 §三)。
|
||||
- ✅ 契约先行门([`contract-first`](.agents/rules/contract-first.md)):Flyway 迁移卫生 + OpenAPI **存在性/结构**(注:挡不住语义破坏;openapi-diff CI 已materialize,需首次 CI 运行验证)。
|
||||
- ✅ loop 机械牙([`AgentsInfraIntegrityTest`](muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/AgentsInfraIntegrityTest.java)):每业务 BC 有 `.agent`、README 索引每篇 `.agents` 文档、总账在——把写回/索引同步从自觉变机械。
|
||||
- ✅ knowledge 蒸馏 / skills / workflow(AI 开发协议)/ 进度总账 + 7 BC `.agent`。
|
||||
- ⏳ 后续:market 写路径 BC 整改、openapi-diff CI 首跑验证、`completed=测试证据`兜底(testFiles)、`dev-baseline` 收敛进 rules、跨域 `.application` 边界。
|
||||
- ⏳ 后续:openapi-diff CI 首跑验证、`completed=测试证据`兜底(testFiles)、`dev-baseline` 收敛进 rules、跨域 `.application` 边界、market-server→member-server pom 坐标收口、**ai/平台预存红测试整改**(CI 接电后暴露,非本轮引入:`MuseAiTaskServiceTest` 桩缺失、`MuseAiEventPublishOutboxMapperTest` 需真实 PG、平台 `QiniuSmsClientTest` 时区)。
|
||||
|
||||
> 进度只进 [`docs/mvp/进度总账.md`](docs/mvp/进度总账.md) + 各模块 `.agent`,不新增状态过程文档。
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
| 砖 | 内容 | 状态 | 机械证据 / 落点 |
|
||||
|---|---|---|---|
|
||||
| P0 | CI 真跑测试(JDK21、去 `-Dmaven.test.skip`)+ 覆盖台账去硬编码 + P0 冻结令 | ✅ | `.github/workflows/maven.yml`、`P1rApiCoverageReportTest`、[脊柱规则](../../.agents/rules/verification-and-anti-false-green.md) |
|
||||
| ① | BC 边界 ArchUnit 门——**通用覆盖全业务 BC 间方向** | ✅ 绿(AI/knowledge 已整改;**market 写路径违例登记待整改**,非全绿) | `BcBoundaryArchTest`(1/0F,0 Architecture Violation);[bc-boundaries](../../.agents/rules/bc-boundaries.md) §三 |
|
||||
| ① | BC 边界 ArchUnit 门——**通用覆盖全业务 BC 间方向** | ✅ **全绿**(AI/knowledge/market 三处直连他域 DAL 违例均已整改,豁免清单清空) | `BcBoundaryArchTest`(1/0F,0 Architecture Violation,`KNOWN_VIOLATION_EXEMPTIONS`=空);[bc-boundaries](../../.agents/rules/bc-boundaries.md) §三 |
|
||||
| ② | 契约先行门(Flyway 迁移卫生 + OpenAPI **存在性/结构**) | ✅ 绿(注:挡不住语义破坏;openapi-diff CI 待首跑验证) | `ContractFirstGateTest`(2/0F);[contract-first](../../.agents/rules/contract-first.md) |
|
||||
| ⑦ | loop 机械牙 + CI 接电(round-2) | ✅ 绿 | `AgentsInfraIntegrityTest`(3/0F);`maven.yml` 触发分支修为 `main` |
|
||||
| ③ | knowledge 蒸馏(定位架构 / 现状基线指针 / 决策) | ✅ | [`.agents/knowledge/`](../../.agents/knowledge/) |
|
||||
@ -20,7 +20,9 @@
|
||||
|
||||
**round-2 加固(2026-06-14,据 Opus 评审)**:CI 触发分支 master→main(此前 CI 从不运行)、BC 门通用化 + 整改 knowledge 违例、loop 装机械牙、覆盖门去魔法数、文档诚实化、openapi-diff materialize 为独立 workflow。
|
||||
|
||||
**后续基建 TODO**:**market→member.dal 写路径 BC 整改**(member 暴露写端口,见 bc-boundaries §三)、openapi-diff CI 首跑验证、`completed=测试证据`兜底(台账加 testFiles 字段 + 门禁)、`dev-baseline` 收敛进 rules、跨域 `.application` 边界门。
|
||||
**market 写路径整改(2026-06-14,ultracode)**:member 暴露写端口 `MuseAccountRecordProjectionApi` + DTO(member-server 实现读写自有 DAL、tenantId 由实现侧从上下文注入防伪造、事务沿用调用方),market 5 类改消费端口、移除 member.dal 依赖 → `KNOWN_VIOLATION_EXEMPTIONS` 清空、BC 门全绿。**附带修复**:round-2 重构 `ContentKnowledgeWorkOwnerFacade` 时遗留的旧测试 `KnowledgeWorkOwnerFacadeTest`(仍断言旧 WorkMapper 行为)已删除,其装配守卫/兜底两用例并入 `ContentKnowledgeWorkOwnerFacadeTest`(5/0F)——此为 round-2 一处假绿(当时构建在平台时区用例处中止、未真正跑到 knowledge),现已补正。
|
||||
|
||||
**后续基建 TODO**:openapi-diff CI 首跑验证、`completed=测试证据`兜底(台账加 testFiles 字段 + 门禁)、`dev-baseline` 收敛进 rules、跨域 `.application` 边界门、market-server→member-server pom 坐标收口(本次只消除 member.dal 代码 import,见 bc-boundaries §三遗留)、**ai/平台预存红测试整改**(CI 接电后将暴露:`MuseAiTaskServiceTest` 桩 `eventPublishOutboxService` 缺失致 11 例 NPE、`MuseAiEventPublishOutboxMapperTest` 需真实 PostgreSQL、平台 `QiniuSmsClientTest` 硬编码北京时区在非 +8 机器失败——均非本轮改动引入)。
|
||||
|
||||
---
|
||||
|
||||
@ -70,4 +72,5 @@
|
||||
2026-06-13 现状基线 + 对抗复盘(诊断"假绿")+ P0 止血(CI 真跑测试/JDK21/去硬编码)
|
||||
2026-06-14 Agent 开发基建六砖(BC 门 + 契约门 + .agents 中枢 + 单一总账)+ 历史文档清理
|
||||
2026-06-14 P1 harness 验证:消除 BC 违例 ContentMuseWorkOwnerFacade(content-api 端口 + AI 改消费)→ ArchUnit 豁免删除、门禁收紧(反向红 31 例 / 正向绿;13+7 单测)
|
||||
2026-06-14 P1 market 写路径整改(ultracode):member 暴露 MuseAccountRecordProjectionApi 写端口 + DTO,market 5 类去 member.dal → 豁免清空、BC 门全绿;附带补正 round-2 knowledge 旧测试假绿(JDK21 scoped 实跑:BcBoundary 1/0F+0 违例、knowledge 5/0F、member 4/0F、market 5 类全绿、契约/loop/覆盖门全绿,BUILD SUCCESS)
|
||||
```
|
||||
|
||||
@ -6,6 +6,7 @@ import cn.iocoder.muse.module.content.api.MuseContentWorkOwnerApi;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
|
||||
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_RESOURCE_FORBIDDEN;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@ -57,4 +58,19 @@ class ContentKnowledgeWorkOwnerFacadeTest extends BaseMockitoUnitTest {
|
||||
verifyNoInteractions(contentWorkOwnerApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_registerOnlyWhenContentApiBeanExists() {
|
||||
// 适配器必须仅在 content 对外契约 Bean 存在时注册;整改后条件从 WorkMapper 改为 MuseContentWorkOwnerApi(承接旧用例的装配守卫)。
|
||||
ConditionalOnBean condition = ContentKnowledgeWorkOwnerFacade.class.getAnnotation(ConditionalOnBean.class);
|
||||
assertEquals(MuseContentWorkOwnerApi.class, condition.value()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedWhenContentAdapterUnavailable() {
|
||||
// 无真实 content adapter 时,兜底 facade 必须失败关闭,不得按 workId 直接放行(承接旧用例的兜底语义)。
|
||||
ServiceException unavailable = assertThrows(ServiceException.class,
|
||||
() -> new UnavailableKnowledgeWorkOwnerFacade().requireWorkOwner(9001L, 2001L));
|
||||
assertEquals(KNOWLEDGE_RESOURCE_FORBIDDEN.getCode(), unavailable.getCode());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
|
||||
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_RESOURCE_FORBIDDEN;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Knowledge 到 Content 作品归属 Facade 测试。
|
||||
*/
|
||||
class KnowledgeWorkOwnerFacadeTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private ContentKnowledgeWorkOwnerFacade contentFacade;
|
||||
@Mock
|
||||
private WorkMapper workMapper;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_requireWorkOwnerThroughContentWorkMapper() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
WorkDO work = new WorkDO();
|
||||
work.setId(9001L);
|
||||
work.setOwnerUserId(2001L);
|
||||
when(workMapper.selectActiveByIdAndOwner(100L, 9001L, 2001L)).thenReturn(work);
|
||||
|
||||
contentFacade.requireWorkOwner(9001L, 2001L);
|
||||
|
||||
verify(workMapper).selectActiveByIdAndOwner(100L, 9001L, 2001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedWhenContentWorkOwnerMissingOrForbidden() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
when(workMapper.selectActiveByIdAndOwner(100L, 9001L, 2002L)).thenReturn(null);
|
||||
|
||||
ServiceException forbidden = assertThrows(ServiceException.class,
|
||||
() -> contentFacade.requireWorkOwner(9001L, 2002L));
|
||||
assertEquals(KNOWLEDGE_RESOURCE_FORBIDDEN.getCode(), forbidden.getCode());
|
||||
|
||||
ServiceException unavailable = assertThrows(ServiceException.class,
|
||||
() -> new UnavailableKnowledgeWorkOwnerFacade().requireWorkOwner(9001L, 2001L));
|
||||
assertEquals(KNOWLEDGE_RESOURCE_FORBIDDEN.getCode(), unavailable.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_registerContentAdapterOnlyWhenWorkMapperExists() {
|
||||
ConditionalOnBean condition = ContentKnowledgeWorkOwnerFacade.class.getAnnotation(ConditionalOnBean.class);
|
||||
|
||||
assertEquals(WorkMapper.class, condition.value()[0]);
|
||||
}
|
||||
|
||||
}
|
||||
@ -19,7 +19,7 @@ import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishDraftMapper
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishRequestMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketReviewEventMapper;
|
||||
import cn.iocoder.muse.module.market.domain.muse.MarketApiVersionGuard;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -377,7 +377,8 @@ public class AdminMarketReviewServiceImpl implements AdminMarketReviewService {
|
||||
snapshot.put("assetId", request.getAssetId());
|
||||
snapshot.put("assetVersionId", request.getAssetVersionId());
|
||||
snapshot.put("sourceOwner", "muse-module-market");
|
||||
AccountRecordProjectionDO projection = AccountRecordProjectionDO.builder()
|
||||
// 投影 DTO 不再设置 tenantId, 由 member 写端口实现侧从 TenantContextHolder 注入。
|
||||
MuseAccountRecordProjectionSaveReqDTO projection = MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(request.getPublisherId())
|
||||
.recordType("publish")
|
||||
.recordId(recordId)
|
||||
@ -390,7 +391,6 @@ public class AdminMarketReviewServiceImpl implements AdminMarketReviewService {
|
||||
.correlationId(commandId)
|
||||
.projectionSnapshot(JsonUtils.toJsonString(snapshot))
|
||||
.build();
|
||||
projection.setTenantId(tenantId);
|
||||
|
||||
MuseMarketAccountProjectionDO outbox = new MuseMarketAccountProjectionDO();
|
||||
outbox.setProjectionId("account-publish-muse_market_publish_request-" + request.getId());
|
||||
|
||||
@ -3,9 +3,9 @@ package cn.iocoder.muse.module.market.application.muse;
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAccountProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.MuseAccountRecordProjectionApi;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import cn.iocoder.muse.module.member.application.account.facade.MarketAccountProjectionFacade;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountRecordProjectionMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -17,8 +17,13 @@ import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_ACCO
|
||||
/**
|
||||
* Market 提供给 member Account 的真实投影 provider。
|
||||
*
|
||||
* <p>该 Bean 实现 member 模块既有 {@link MarketAccountProjectionFacade},用于替换默认 fail-closed
|
||||
* unavailable facade。Account 查询仍只读 member 的 muse_account_record_projection。</p>
|
||||
* <p>该 Bean 实现 member 模块既有 {@link MarketAccountProjectionFacade}(该端口在 member-server, 非 .dal, 合规),
|
||||
* 用于替换默认 fail-closed unavailable facade,让读侧"投影已接入"判定不变。Account 查询仍只读 member 的
|
||||
* muse_account_record_projection。</p>
|
||||
*
|
||||
* <p>投影【写入 member 表】不再直连 member.dal, 改为调用 member 对外写端口 {@link MuseAccountRecordProjectionApi}
|
||||
* (本进程内 Bean), 由 member-server 实现侧读写自有 DAL —— 消除 market→member.dal 写路径 BC 违例。
|
||||
* Market 自有 outbox(MuseMarketAccountProjectionDO)仍保留在本 provider 内, 与 member 写端口解耦。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -27,7 +32,7 @@ public class MarketAccountProjectionProvider implements MarketAccountProjectionF
|
||||
|
||||
private static final Set<String> SUPPORTED_RECORD_TYPES = Set.of("purchase", "license", "publish");
|
||||
|
||||
private final AccountRecordProjectionMapper accountRecordProjectionMapper;
|
||||
private final MuseAccountRecordProjectionApi projectionApi;
|
||||
private final MarketAccountProjectionOutboxService outboxService;
|
||||
|
||||
@Override
|
||||
@ -41,20 +46,20 @@ public class MarketAccountProjectionProvider implements MarketAccountProjectionF
|
||||
/**
|
||||
* 写 purchase Account 查询读模型,并同步记录 Market outbox 状态。
|
||||
*
|
||||
* @param projection member Account 读模型
|
||||
* @param projection member Account 读模型写入契约
|
||||
* @param outbox Market 侧投影同步状态
|
||||
*/
|
||||
public void projectPurchase(AccountRecordProjectionDO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
public void projectPurchase(MuseAccountRecordProjectionSaveReqDTO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
project("purchase", projection, outbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写 license Account 查询读模型,并同步记录 Market outbox 状态。
|
||||
*
|
||||
* @param projection member Account 读模型
|
||||
* @param projection member Account 读模型写入契约
|
||||
* @param outbox Market 侧投影同步状态
|
||||
*/
|
||||
public void projectLicense(AccountRecordProjectionDO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
public void projectLicense(MuseAccountRecordProjectionSaveReqDTO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
project("license", projection, outbox);
|
||||
}
|
||||
|
||||
@ -63,22 +68,24 @@ public class MarketAccountProjectionProvider implements MarketAccountProjectionF
|
||||
*
|
||||
* <p>publish 投影只表达 Market 发布审核状态,不能把审核通过误写成 Content/AI/Knowledge 目标 owner 事实。</p>
|
||||
*
|
||||
* @param projection member Account 读模型
|
||||
* @param projection member Account 读模型写入契约
|
||||
* @param outbox Market 侧投影同步状态
|
||||
*/
|
||||
public void projectPublish(AccountRecordProjectionDO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
public void projectPublish(MuseAccountRecordProjectionSaveReqDTO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
project("publish", projection, outbox);
|
||||
}
|
||||
|
||||
private void project(String recordType, AccountRecordProjectionDO projection, MuseMarketAccountProjectionDO outbox) {
|
||||
private void project(String recordType, MuseAccountRecordProjectionSaveReqDTO projection,
|
||||
MuseMarketAccountProjectionDO outbox) {
|
||||
requireProjectionAvailable(recordType);
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
projection.setTenantId(tenantId);
|
||||
// 投影 DTO 的 tenantId 不在 market 侧设置;统一由 member 写端口实现侧从 TenantContextHolder 注入, 杜绝跨域伪造租户。
|
||||
outbox.setTenantId(tenantId);
|
||||
outbox.setRecordType(recordType);
|
||||
try {
|
||||
// 真实 Account 查询读模型只写 member 表;license 在 purchase 后可被 install 推进为 installed。
|
||||
upsertAccountProjection(tenantId, recordType, projection);
|
||||
// 真实 Account 查询读模型只写 member 表(经写端口);license 在 purchase 后可被 install 推进为 installed。
|
||||
// 该写端口是进程内 Bean 调用, 事务上下文沿用本方法外层 @Transactional, 写失败可让 market 业务一起回滚。
|
||||
projectionApi.upsertRecordProjection(projection);
|
||||
outboxService.recordSynced(outbox);
|
||||
log.info("[project][Market Account 投影成功,tenantId={}, recordType={}, recordId={}, projectionId={}]",
|
||||
tenantId, recordType, projection.getRecordId(), outbox.getProjectionId());
|
||||
@ -90,21 +97,4 @@ public class MarketAccountProjectionProvider implements MarketAccountProjectionF
|
||||
throw new ServiceException(MARKET_ACCOUNT_PROJECTION_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertAccountProjection(Long tenantId, String recordType, AccountRecordProjectionDO projection) {
|
||||
AccountRecordProjectionDO existing = accountRecordProjectionMapper
|
||||
.selectByTenantIdAndRecordTypeAndRecordId(tenantId, recordType, projection.getRecordId());
|
||||
if (existing == null) {
|
||||
accountRecordProjectionMapper.insert(projection);
|
||||
return;
|
||||
}
|
||||
projection.setTenantId(tenantId);
|
||||
projection.setId(existing.getId());
|
||||
int updatedRows = accountRecordProjectionMapper.updateByRecordTypeAndRecordId(projection);
|
||||
if (updatedRows != 1) {
|
||||
// Account 读模型更新失败必须显式暴露,避免 Market 返回安装成功但 Account 仍显示旧授权状态。
|
||||
throw new IllegalStateException("Account 投影更新失败,recordType="
|
||||
+ recordType + ", recordId=" + projection.getRecordId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketInstallationD
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketInstallationMapper;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -141,11 +141,12 @@ public class MarketInstallServiceImpl implements MarketInstallService {
|
||||
operationId + ":" + commandId, requestHash);
|
||||
}
|
||||
|
||||
private AccountRecordProjectionDO licenseProjection(Long tenantId, Long loginUserId, String commandId,
|
||||
MuseMarketAssetDO asset,
|
||||
MuseMarketAuthorizationSnapshotDO authorization,
|
||||
Long versionId) {
|
||||
AccountRecordProjectionDO projection = AccountRecordProjectionDO.builder()
|
||||
private MuseAccountRecordProjectionSaveReqDTO licenseProjection(Long tenantId, Long loginUserId, String commandId,
|
||||
MuseMarketAssetDO asset,
|
||||
MuseMarketAuthorizationSnapshotDO authorization,
|
||||
Long versionId) {
|
||||
// 投影 DTO 不再设置 tenantId, 由 member 写端口实现侧从 TenantContextHolder 注入。
|
||||
return MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(loginUserId)
|
||||
.recordType("license")
|
||||
.recordId(authorization.getLicenseId())
|
||||
@ -166,8 +167,6 @@ public class MarketInstallServiceImpl implements MarketInstallService {
|
||||
"actionPolicy", Map.of("installPolicy", "allowed", "bindPolicy", "allowed",
|
||||
"recheckReasons", List.of()))))
|
||||
.build();
|
||||
projection.setTenantId(tenantId);
|
||||
return projection;
|
||||
}
|
||||
|
||||
private MuseMarketAccountProjectionDO accountOutbox(Long tenantId, String commandId, String requestHash,
|
||||
|
||||
@ -11,7 +11,7 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPurchaseDO;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPurchaseMapper;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -212,10 +212,11 @@ public class MarketLicenseServiceImpl implements MarketLicenseService {
|
||||
+ tenantId + ", userId=" + loginUserId + ", assetId=" + asset.getId() + ", commandId=" + commandId);
|
||||
}
|
||||
|
||||
private AccountRecordProjectionDO purchaseProjection(Long tenantId, Long loginUserId, String commandId,
|
||||
MuseMarketAssetDO asset, String externalOrderRef,
|
||||
Map<String, Object> licenseSnapshot) {
|
||||
AccountRecordProjectionDO projection = AccountRecordProjectionDO.builder()
|
||||
private MuseAccountRecordProjectionSaveReqDTO purchaseProjection(Long tenantId, Long loginUserId, String commandId,
|
||||
MuseMarketAssetDO asset, String externalOrderRef,
|
||||
Map<String, Object> licenseSnapshot) {
|
||||
// 投影 DTO 不再设置 tenantId, 由 member 写端口实现侧从 TenantContextHolder 注入。
|
||||
return MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(loginUserId)
|
||||
.recordType("purchase")
|
||||
.recordId("purchase-" + tenantId + "-" + asset.getId() + "-" + loginUserId)
|
||||
@ -234,15 +235,14 @@ public class MarketLicenseServiceImpl implements MarketLicenseService {
|
||||
"authorizationResult", "授权完成",
|
||||
"licenseSnapshot", licenseSnapshot)))
|
||||
.build();
|
||||
projection.setTenantId(tenantId);
|
||||
return projection;
|
||||
}
|
||||
|
||||
private AccountRecordProjectionDO licenseProjection(Long tenantId, Long loginUserId, String commandId,
|
||||
MuseMarketAssetDO asset, String sourceStatus,
|
||||
String installSummary,
|
||||
Map<String, Object> licenseSnapshot) {
|
||||
AccountRecordProjectionDO projection = AccountRecordProjectionDO.builder()
|
||||
private MuseAccountRecordProjectionSaveReqDTO licenseProjection(Long tenantId, Long loginUserId, String commandId,
|
||||
MuseMarketAssetDO asset, String sourceStatus,
|
||||
String installSummary,
|
||||
Map<String, Object> licenseSnapshot) {
|
||||
// 投影 DTO 不再设置 tenantId, 由 member 写端口实现侧从 TenantContextHolder 注入。
|
||||
return MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(loginUserId)
|
||||
.recordType("license")
|
||||
.recordId(licenseKey(tenantId, asset.getId(), loginUserId))
|
||||
@ -263,8 +263,6 @@ public class MarketLicenseServiceImpl implements MarketLicenseService {
|
||||
"actionPolicy", linkedMap("installPolicy", "allowed", "bindPolicy", "allowed",
|
||||
"recheckReasons", java.util.List.of()))))
|
||||
.build();
|
||||
projection.setTenantId(tenantId);
|
||||
return projection;
|
||||
}
|
||||
|
||||
private MuseMarketAccountProjectionDO accountOutbox(Long tenantId, String recordType, String commandId,
|
||||
|
||||
@ -10,12 +10,12 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPublishCheckD
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPublishDraftDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPublishRequestDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketReviewEventDO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishCheckMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishDraftMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishRequestMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketReviewEventMapper;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -381,7 +381,8 @@ public class MarketPublishServiceImpl implements MarketPublishService {
|
||||
snapshot.put("requestId", request == null ? null : request.getId());
|
||||
snapshot.put("internalReviewStatus", request == null ? reviewStatus : request.getStatus());
|
||||
snapshot.put("sourceOwner", SOURCE_OWNER_MARKET);
|
||||
AccountRecordProjectionDO projection = AccountRecordProjectionDO.builder()
|
||||
// 投影 DTO 不再设置 tenantId, 由 member 写端口实现侧从 TenantContextHolder 注入。
|
||||
MuseAccountRecordProjectionSaveReqDTO projection = MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(loginUserId)
|
||||
.recordType("publish")
|
||||
.recordId(recordId)
|
||||
@ -395,7 +396,6 @@ public class MarketPublishServiceImpl implements MarketPublishService {
|
||||
.correlationId(commandId)
|
||||
.projectionSnapshot(JsonUtils.toJsonString(snapshot))
|
||||
.build();
|
||||
projection.setTenantId(tenantId);
|
||||
|
||||
MuseMarketAccountProjectionDO outbox = new MuseMarketAccountProjectionDO();
|
||||
outbox.setProjectionId("account-publish-" + sourceTable + "-" + sourceId);
|
||||
|
||||
@ -19,7 +19,7 @@ import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishCheckMapper
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishDraftMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishRequestMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketReviewEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
@ -182,7 +182,7 @@ class AdminMarketReviewServiceTest extends BaseMockitoUnitTest {
|
||||
"consumed", "admin_reviewed");
|
||||
verify(assetMapper).markListedById(100L, 5001L, 8401L);
|
||||
verify(assetVersionMapper).markListedById(100L, 8401L, 5001L);
|
||||
verify(accountProjectionProvider).projectPublish(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPublish(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101-9001".equals(projection.getRecordId())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
@ -512,7 +512,7 @@ class AdminMarketReviewServiceTest extends BaseMockitoUnitTest {
|
||||
verify(publishCheckMapper).consumeForAdminReview(100L, 8202L, 9001L, "draft-8101",
|
||||
"consumed", "admin_reviewed");
|
||||
verify(assetMapper, never()).markListedById(any(), any(), any());
|
||||
verify(accountProjectionProvider).projectPublish(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPublish(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101-9001".equals(projection.getRecordId())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
@ -551,7 +551,7 @@ class AdminMarketReviewServiceTest extends BaseMockitoUnitTest {
|
||||
"check-8202", "权利证据不成立", List.of("rights"), false, List.of()));
|
||||
|
||||
assertEquals("rejected", result.status());
|
||||
verify(accountProjectionProvider).projectPublish(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPublish(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101-9001".equals(projection.getRecordId())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
|
||||
@ -4,9 +4,9 @@ import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAccountProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.MuseAccountRecordProjectionApi;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import cn.iocoder.muse.module.member.application.account.facade.MarketAccountProjectionFacade;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountRecordProjectionMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
@ -16,25 +16,27 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_ACCOUNT_PROJECTION_UNAVAILABLE;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Market 到 Account 投影 provider 测试。
|
||||
*
|
||||
* <p>投影【写入 member 表】已从直连 member.dal Mapper 改为调用 member 对外写端口 {@link MuseAccountRecordProjectionApi};
|
||||
* 故本测试只验证 provider 这一层的职责:把投影委派给写端口 + 维护 market 自有 outbox(synced/blocked)+ 失败语义。
|
||||
* "insert vs update 分支选择 / 租户隔离"语义已随实现搬到 member 侧, 由 {@code MuseAccountRecordProjectionApiImplTest} 覆盖。</p>
|
||||
*/
|
||||
class MarketAccountProjectionProviderTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MarketAccountProjectionProvider provider;
|
||||
@Mock
|
||||
private AccountRecordProjectionMapper accountRecordProjectionMapper;
|
||||
private MuseAccountRecordProjectionApi projectionApi;
|
||||
@Mock
|
||||
private MarketAccountProjectionOutboxService outboxService;
|
||||
|
||||
@ -52,66 +54,38 @@ class MarketAccountProjectionProviderTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_writeMemberProjectionAndMarketSyncedOutbox() {
|
||||
void should_delegatePurchaseToWritePortAndRecordSyncedOutbox() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
|
||||
provider.projectPurchase(projection("purchase", "purchase-100-1"), marketOutbox("purchase", "purchase-100-1"));
|
||||
|
||||
verify(accountRecordProjectionMapper).selectByTenantIdAndRecordTypeAndRecordId(100L,
|
||||
"purchase", "purchase-100-1");
|
||||
verify(accountRecordProjectionMapper).insert(org.mockito.ArgumentMatchers.<AccountRecordProjectionDO>argThat(projection -> "purchase".equals(projection.getRecordType())
|
||||
&& "purchase-100-1".equals(projection.getRecordId())
|
||||
&& Long.valueOf(100L).equals(projection.getTenantId())));
|
||||
// 写 member 表改为走端口;provider 只负责把投影 DTO 委派给写端口。
|
||||
verify(projectionApi).upsertRecordProjection(org.mockito.ArgumentMatchers.<MuseAccountRecordProjectionSaveReqDTO>argThat(projection ->
|
||||
"purchase".equals(projection.getRecordType())
|
||||
&& "purchase-100-1".equals(projection.getRecordId())));
|
||||
verify(outboxService).recordSynced(org.mockito.ArgumentMatchers.<MuseMarketAccountProjectionDO>argThat(outbox ->
|
||||
"purchase".equals(outbox.getRecordType()) && Long.valueOf(100L).equals(outbox.getTenantId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_writePublishProjectionAndMarketSyncedOutbox() {
|
||||
void should_delegatePublishToWritePortAndRecordSyncedOutbox() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
|
||||
provider.projectPublish(projection("publish", "publish-100-8101"), marketOutbox("publish", "publish-100-8101"));
|
||||
|
||||
verify(accountRecordProjectionMapper).selectByTenantIdAndRecordTypeAndRecordId(100L,
|
||||
"publish", "publish-100-8101");
|
||||
verify(accountRecordProjectionMapper).insert(org.mockito.ArgumentMatchers.<AccountRecordProjectionDO>argThat(projection ->
|
||||
verify(projectionApi).upsertRecordProjection(org.mockito.ArgumentMatchers.<MuseAccountRecordProjectionSaveReqDTO>argThat(projection ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101".equals(projection.getRecordId())
|
||||
&& Long.valueOf(100L).equals(projection.getTenantId())));
|
||||
&& "publish-100-8101".equals(projection.getRecordId())));
|
||||
verify(outboxService).recordSynced(org.mockito.ArgumentMatchers.<MuseMarketAccountProjectionDO>argThat(outbox ->
|
||||
"publish".equals(outbox.getRecordType()) && Long.valueOf(100L).equals(outbox.getTenantId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_upsertOnlyCurrentTenantProjection_whenSameRecordIdExistsInOtherTenant() {
|
||||
TenantContextHolder.setTenantId(200L);
|
||||
AccountRecordProjectionDO existing = projection("publish", "publish-shared");
|
||||
existing.setId(42L);
|
||||
existing.setTenantId(200L);
|
||||
when(accountRecordProjectionMapper.selectByTenantIdAndRecordTypeAndRecordId(200L,
|
||||
"publish", "publish-shared")).thenReturn(existing);
|
||||
when(accountRecordProjectionMapper.updateByRecordTypeAndRecordId(any(AccountRecordProjectionDO.class)))
|
||||
.thenReturn(1);
|
||||
|
||||
provider.projectPublish(projection("publish", "publish-shared"), marketOutbox("publish", "publish-shared"));
|
||||
|
||||
verify(accountRecordProjectionMapper).selectByTenantIdAndRecordTypeAndRecordId(200L,
|
||||
"publish", "publish-shared");
|
||||
verify(accountRecordProjectionMapper).updateByRecordTypeAndRecordId(
|
||||
org.mockito.ArgumentMatchers.<AccountRecordProjectionDO>argThat(projection ->
|
||||
Long.valueOf(42L).equals(projection.getId())
|
||||
&& Long.valueOf(200L).equals(projection.getTenantId())
|
||||
&& "publish".equals(projection.getRecordType())
|
||||
&& "publish-shared".equals(projection.getRecordId())));
|
||||
verify(outboxService).recordSynced(org.mockito.ArgumentMatchers.<MuseMarketAccountProjectionDO>argThat(outbox ->
|
||||
Long.valueOf(200L).equals(outbox.getTenantId())
|
||||
&& "publish".equals(outbox.getRecordType())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_writeBlockedOutboxAndThrow_whenMemberProjectionMapperFails() {
|
||||
void should_writeBlockedOutboxAndThrow_whenWritePortFails() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
doThrow(new IllegalStateException("db down")).when(accountRecordProjectionMapper).insert(any(AccountRecordProjectionDO.class));
|
||||
// 写端口失败 -> provider 必须写 blocked outbox 留证, 再上抛 MARKET_ACCOUNT_PROJECTION_UNAVAILABLE 阻止伪成功。
|
||||
doThrow(new IllegalStateException("db down")).when(projectionApi)
|
||||
.upsertRecordProjection(any(MuseAccountRecordProjectionSaveReqDTO.class));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> provider.projectLicense(projection("license", "license-100-1"), marketOutbox("license", "license-100-1")));
|
||||
@ -131,8 +105,8 @@ class MarketAccountProjectionProviderTest extends BaseMockitoUnitTest {
|
||||
assertEquals(Propagation.REQUIRES_NEW, transactional.propagation());
|
||||
}
|
||||
|
||||
private static AccountRecordProjectionDO projection(String recordType, String recordId) {
|
||||
return AccountRecordProjectionDO.builder()
|
||||
private static MuseAccountRecordProjectionSaveReqDTO projection(String recordType, String recordId) {
|
||||
return MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(9001L)
|
||||
.recordType(recordType)
|
||||
.recordId(recordId)
|
||||
|
||||
@ -10,7 +10,7 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketInstallationD
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketInstallationMapper;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
@ -98,7 +98,7 @@ class MarketInstallServiceTest extends BaseMockitoUnitTest {
|
||||
&& "installed".equals(installation.getStatus())
|
||||
&& Long.valueOf(7001L).equals(installation.getAuthorizationSnapshotId())
|
||||
&& "cmd-install".equals(installation.getCommandId())));
|
||||
verify(accountProjectionProvider).projectLicense(argThat((AccountRecordProjectionDO projection) -> "license".equals(projection.getRecordType())
|
||||
verify(accountProjectionProvider).projectLicense(argThat((MuseAccountRecordProjectionSaveReqDTO projection) -> "license".equals(projection.getRecordType())
|
||||
&& "installed".equals(projection.getSourceStatus())
|
||||
&& projection.getProjectionSnapshot().contains("\"installSummary\":\"已安装\"")
|
||||
&& projection.getProjectionSnapshot().contains("\"assetType\":\"agent\"")),
|
||||
@ -119,7 +119,7 @@ class MarketInstallServiceTest extends BaseMockitoUnitTest {
|
||||
service.installMarketplaceAsset(9001L, "1", "cmd-install", 1001L, 3001L, true);
|
||||
|
||||
verify(installationMapper).insertIgnore(any(MuseMarketInstallationDO.class));
|
||||
verify(accountProjectionProvider).projectLicense(any(AccountRecordProjectionDO.class), any());
|
||||
verify(accountProjectionProvider).projectLicense(any(MuseAccountRecordProjectionSaveReqDTO.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -10,7 +10,7 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPurchaseDO;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPurchaseMapper;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@ -107,12 +107,12 @@ class MarketLicenseServiceTest extends BaseMockitoUnitTest {
|
||||
&& "muse-module-market".equals(snapshot.getSourceOwner())
|
||||
&& "agent".equals(snapshot.getSourceType())
|
||||
&& "1001".equals(snapshot.getSourceId())));
|
||||
verify(accountProjectionProvider).projectPurchase(argThat((AccountRecordProjectionDO projection) -> "purchase".equals(projection.getRecordType())
|
||||
verify(accountProjectionProvider).projectPurchase(argThat((MuseAccountRecordProjectionSaveReqDTO projection) -> "purchase".equals(projection.getRecordType())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
&& "completed".equals(projection.getSourceStatus())
|
||||
&& projection.getProjectionSnapshot().contains("\"externalOrderRef\":\"EXT-ORDER-1\"")),
|
||||
any());
|
||||
verify(accountProjectionProvider).projectLicense(argThat((AccountRecordProjectionDO projection) -> "license".equals(projection.getRecordType())
|
||||
verify(accountProjectionProvider).projectLicense(argThat((MuseAccountRecordProjectionSaveReqDTO projection) -> "license".equals(projection.getRecordType())
|
||||
&& "active".equals(projection.getSourceStatus())
|
||||
&& projection.getProjectionSnapshot().contains("\"assetType\":\"agent\"")),
|
||||
any());
|
||||
@ -140,7 +140,7 @@ class MarketLicenseServiceTest extends BaseMockitoUnitTest {
|
||||
verify(purchaseMapper).insertIgnore(argThat(purchase -> "cmd-buy-null".equals(purchase.getCommandId())
|
||||
&& (purchase.getPurchasePayload().contains("\"externalOrderRef\":null")
|
||||
|| !purchase.getPurchasePayload().contains("externalOrderRef"))));
|
||||
verify(accountProjectionProvider).projectPurchase(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPurchase(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"purchase".equals(projection.getRecordType())
|
||||
&& (projection.getProjectionSnapshot().contains("\"externalOrderRef\":null")
|
||||
|| !projection.getProjectionSnapshot().contains("externalOrderRef"))),
|
||||
|
||||
@ -16,7 +16,7 @@ import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishCheckMapper
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishDraftMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketPublishRequestMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketReviewEventMapper;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
@ -103,7 +103,7 @@ class MarketPublishServiceTest extends BaseMockitoUnitTest {
|
||||
&& "request-hash".equals(inserted.getRequestHash())
|
||||
&& inserted.getDraftSnapshot().contains("\"workAssetMode\":\"read_only_favorite_license\"")
|
||||
&& inserted.getDraftSnapshot().contains("\"materialHash\":\"material-hash\"")));
|
||||
verify(accountProjectionProvider).projectPublish(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPublish(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101-9001".equals(projection.getRecordId())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
@ -271,7 +271,7 @@ class MarketPublishServiceTest extends BaseMockitoUnitTest {
|
||||
&& "submitted".equals(inserted.getStatus())
|
||||
&& inserted.getReviewSnapshot().contains("\"marketPublishCheckId\":8201")
|
||||
&& inserted.getReviewSnapshot().contains("\"materialHash\":\"hash-draft\"")));
|
||||
verify(accountProjectionProvider).projectPublish(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPublish(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101-9001".equals(projection.getRecordId())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
@ -372,7 +372,7 @@ class MarketPublishServiceTest extends BaseMockitoUnitTest {
|
||||
Long.valueOf(8301L).equals(updated.getId())
|
||||
&& "withdrawn".equals(updated.getStatus())
|
||||
&& updated.getReviewNote().contains("用户撤回")), org.mockito.ArgumentMatchers.eq("submitted"));
|
||||
verify(accountProjectionProvider).projectPublish(argThat((AccountRecordProjectionDO projection) ->
|
||||
verify(accountProjectionProvider).projectPublish(argThat((MuseAccountRecordProjectionSaveReqDTO projection) ->
|
||||
"publish".equals(projection.getRecordType())
|
||||
&& "publish-100-8101-9001".equals(projection.getRecordId())
|
||||
&& Long.valueOf(9001L).equals(projection.getAccountUserId())
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package cn.iocoder.muse.module.member.api.account;
|
||||
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
|
||||
/**
|
||||
* Account 购买/授权/发布投影的对外写端口(BC 对外契约)。
|
||||
*
|
||||
* <p>供 Market 域在【不直连 member.dal】前提下, 把自己产生的 purchase/license/publish 投影
|
||||
* upsert 进 member 的 {@code muse_account_record_projection} 读模型。
|
||||
* 由 member-server 的 {@code MuseAccountRecordProjectionApiImpl} 实现(读写 member 自有 DAL, 本域读本域合规)。</p>
|
||||
*
|
||||
* <p>recordType 已内含于 DTO, upsert 唯一键 = (tenantId, recordType, recordId);
|
||||
* tenantId 由 member-server 实现侧从 {@code TenantContextHolder} 取, DTO 不带 tenantId(避免他域伪造租户)。</p>
|
||||
*
|
||||
* <p>本端口是【本地进程内 Bean】端口, 不加 {@code @FeignClient}(参照 content 先例 {@code MuseContentWorkOwnerApi};
|
||||
* market 与 member 同进程聚合, 无需 RPC)。消费方依赖本 -api 契约, 不得依赖 {@code member.dal}
|
||||
* (由 ArchUnit BcBoundaryArchTest 机械约束)。</p>
|
||||
*
|
||||
* <p>注:故意不把 member 既有【读校验】端口 {@code MarketAccountProjectionFacade.requireProjectionAvailable}
|
||||
* 合并进来 —— 那是 member 读侧 fail-closed 判定(member-server 内部用, 见 AccountMarketRecordServiceImpl),
|
||||
* 与本【写】端口职责不同, 保持分离。</p>
|
||||
*/
|
||||
public interface MuseAccountRecordProjectionApi {
|
||||
|
||||
/**
|
||||
* 按 (当前租户, recordType, recordId) upsert 一条 Account 投影。
|
||||
*
|
||||
* <p>存在则按唯一键 update, 不存在则 insert;update 影响行数 != 1 抛 {@link IllegalStateException}(防伪成功)。
|
||||
* 不吞异常:失败原样上抛, 由调用方(market provider)写自己的 outbox blocked 并决定是否回滚。</p>
|
||||
*
|
||||
* @param projection member Account 投影写入契约(含 recordType)
|
||||
*/
|
||||
void upsertRecordProjection(MuseAccountRecordProjectionSaveReqDTO projection);
|
||||
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package cn.iocoder.muse.module.member.api.account.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Account 购买/授权/发布投影写入契约 DTO(member 对外写端口入参)。
|
||||
*
|
||||
* <p>字段与 member.dal 的 {@code AccountRecordProjectionDO} 一一对应, 但【刻意剔除 DAL 专属字段】:
|
||||
* <ul>
|
||||
* <li><b>不含 tenantId</b> —— 由 member-server 实现侧统一 {@code setTenantId(TenantContextHolder.getRequiredTenantId())},
|
||||
* 杜绝跨域伪造租户;</li>
|
||||
* <li><b>不含 id</b> —— upsert 命中行的 id 由 member 实现侧回查 existing 后回填;</li>
|
||||
* <li>{@code projectionSnapshot} 是【已序列化 JSON String】(market 侧 {@code JsonUtils.toJsonString} 产出),
|
||||
* member 实现侧直接交给既有 {@code JsonbStringTypeHandler} 落 JSONB;DTO 只承载 String,
|
||||
* 不引 typeHandler、不引 member.dal 任何类型。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MuseAccountRecordProjectionSaveReqDTO {
|
||||
|
||||
/** 投影归属用户 */
|
||||
private Long accountUserId;
|
||||
/** purchase / license / publish, upsert 唯一键之一 */
|
||||
private String recordType;
|
||||
/** upsert 唯一键之一 */
|
||||
private String recordId;
|
||||
/** 来源表名(market 侧事实表) */
|
||||
private String sourceTable;
|
||||
/** 来源业务键 */
|
||||
private String sourceId;
|
||||
/** 来源版本 */
|
||||
private String sourceRevision;
|
||||
/** 投影对外可表达状态 */
|
||||
private String sourceStatus;
|
||||
/** 投影标题 */
|
||||
private String title;
|
||||
/** 仅 purchase 用;license/publish 传 null */
|
||||
private Long amount;
|
||||
/** 业务发生时间 */
|
||||
private LocalDateTime occurredAt;
|
||||
/** = commandId, 贯穿幂等/审计 */
|
||||
private String correlationId;
|
||||
/** JSON 字符串;DTO 只承载 String, JSONB typeHandler 留在 member.dal */
|
||||
private String projectionSnapshot;
|
||||
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package cn.iocoder.muse.module.member.api.account;
|
||||
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountRecordProjectionMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* {@link MuseAccountRecordProjectionApi} 的 member 实现。
|
||||
*
|
||||
* <p>读写 member <b>自有</b> DAL(本域读本域, 合规), 接管原 {@code MarketAccountProjectionProvider.upsertAccountProjection}
|
||||
* 的全部 upsert 语义(整体平移, 不改行为)。这是把"market 直写 member.dal"历史违例收口到对外写契约背后的落点
|
||||
* (见 .agents/rules/bc-boundaries.md §三)。</p>
|
||||
*
|
||||
* <p><b>【事务红线】本方法绝不可加 {@code @Transactional}, 尤其不能用 {@code REQUIRES_NEW} 或任何 Propagation 切换。</b>
|
||||
* 原实现下该 upsert 与 market 业务方法({@code @Transactional})共用同一物理连接/事务, 写失败需让 market 外层回滚。
|
||||
* 本端口是进程内普通 Bean 调用, 事务上下文自然沿用调用方, 与现状等价;一旦把投影写拆成独立事务,
|
||||
* 会出现 market 业务回滚但投影已落库的脏数据, 破坏 market 业务与投影的原子性。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MuseAccountRecordProjectionApiImpl implements MuseAccountRecordProjectionApi {
|
||||
|
||||
@Resource
|
||||
private AccountRecordProjectionMapper accountRecordProjectionMapper;
|
||||
|
||||
@Override
|
||||
public void upsertRecordProjection(MuseAccountRecordProjectionSaveReqDTO projection) {
|
||||
// tenantId 一律由实现侧从上下文取, 杜绝调用方(他域)伪造租户;DTO 本就不含 tenantId。
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
String recordType = projection.getRecordType();
|
||||
String recordId = projection.getRecordId();
|
||||
AccountRecordProjectionDO projectionDO = toDO(projection);
|
||||
projectionDO.setTenantId(tenantId);
|
||||
|
||||
AccountRecordProjectionDO existing = accountRecordProjectionMapper
|
||||
.selectByTenantIdAndRecordTypeAndRecordId(tenantId, recordType, recordId);
|
||||
if (existing == null) {
|
||||
// 唯一键 (tenant_id, record_type, record_id) 未命中 -> 首次写入。
|
||||
accountRecordProjectionMapper.insert(projectionDO);
|
||||
log.info("[upsertRecordProjection][member Account 投影 insert, tenantId={}, recordType={}, recordId={}]",
|
||||
tenantId, recordType, recordId);
|
||||
return;
|
||||
}
|
||||
// 命中已有行:回填 existing.id + 再次确保 tenantId, 按唯一键精确 update。
|
||||
projectionDO.setId(existing.getId());
|
||||
projectionDO.setTenantId(tenantId);
|
||||
int updatedRows = accountRecordProjectionMapper.updateByRecordTypeAndRecordId(projectionDO);
|
||||
if (updatedRows != 1) {
|
||||
// Account 读模型更新失败必须显式暴露, 避免 Market 返回成功但 Account 仍显示旧状态(文案沿用原 provider)。
|
||||
throw new IllegalStateException("Account 投影更新失败,recordType="
|
||||
+ recordType + ", recordId=" + recordId);
|
||||
}
|
||||
log.info("[upsertRecordProjection][member Account 投影 update, tenantId={}, recordType={}, recordId={}, id={}]",
|
||||
tenantId, recordType, recordId, existing.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把对外 DTO 手写逐字段拷贝成 member 自有 DO(不给 MapStruct 加表面契约)。
|
||||
*
|
||||
* <p>tenantId/id 不在这里设置:由 {@link #upsertRecordProjection} 统一注入;
|
||||
* projectionSnapshot 直接透传 JSON String, 落库由 DO 上的 {@code JsonbStringTypeHandler} 负责。</p>
|
||||
*/
|
||||
private AccountRecordProjectionDO toDO(MuseAccountRecordProjectionSaveReqDTO dto) {
|
||||
return AccountRecordProjectionDO.builder()
|
||||
.accountUserId(dto.getAccountUserId())
|
||||
.recordType(dto.getRecordType())
|
||||
.recordId(dto.getRecordId())
|
||||
.sourceTable(dto.getSourceTable())
|
||||
.sourceId(dto.getSourceId())
|
||||
.sourceRevision(dto.getSourceRevision())
|
||||
.sourceStatus(dto.getSourceStatus())
|
||||
.title(dto.getTitle())
|
||||
.amount(dto.getAmount())
|
||||
.occurredAt(dto.getOccurredAt())
|
||||
.correlationId(dto.getCorrelationId())
|
||||
.projectionSnapshot(dto.getProjectionSnapshot())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
package cn.iocoder.muse.module.member.api.account;
|
||||
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import cn.iocoder.muse.module.member.dal.mysql.account.AccountRecordProjectionMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link MuseAccountRecordProjectionApiImpl} 单测:验证 upsert 的 insert/update 分支、
|
||||
* update rows!=1 抛错、以及 tenantId 由 {@code TenantContextHolder} 注入而非 DTO 承载。
|
||||
*
|
||||
* <p>该语义原在 market 的 {@code MarketAccountProjectionProvider.upsertAccountProjection}, 现整体平移到 member 实现侧,
|
||||
* 测试也一并迁移到此(覆盖不丢)。</p>
|
||||
*/
|
||||
class MuseAccountRecordProjectionApiImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MuseAccountRecordProjectionApiImpl api;
|
||||
@Mock
|
||||
private AccountRecordProjectionMapper accountRecordProjectionMapper;
|
||||
|
||||
@AfterEach
|
||||
void clearTenant() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_insertWhenRecordAbsent() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
when(accountRecordProjectionMapper.selectByTenantIdAndRecordTypeAndRecordId(100L,
|
||||
"purchase", "purchase-100-1")).thenReturn(null);
|
||||
|
||||
api.upsertRecordProjection(saveReq("purchase", "purchase-100-1"));
|
||||
|
||||
verify(accountRecordProjectionMapper).selectByTenantIdAndRecordTypeAndRecordId(100L,
|
||||
"purchase", "purchase-100-1");
|
||||
// 未命中 -> insert;且 tenantId 必须由实现侧从上下文注入到 DO(DTO 不带 tenantId)。
|
||||
verify(accountRecordProjectionMapper).insert(org.mockito.ArgumentMatchers.<AccountRecordProjectionDO>argThat(projection ->
|
||||
"purchase".equals(projection.getRecordType())
|
||||
&& "purchase-100-1".equals(projection.getRecordId())
|
||||
&& Long.valueOf(100L).equals(projection.getTenantId())));
|
||||
verify(accountRecordProjectionMapper, never()).updateByRecordTypeAndRecordId(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_updateAndBackfillExistingIdWhenRecordPresent() {
|
||||
TenantContextHolder.setTenantId(200L);
|
||||
AccountRecordProjectionDO existing = new AccountRecordProjectionDO();
|
||||
existing.setId(42L);
|
||||
existing.setTenantId(200L);
|
||||
when(accountRecordProjectionMapper.selectByTenantIdAndRecordTypeAndRecordId(200L,
|
||||
"publish", "publish-shared")).thenReturn(existing);
|
||||
when(accountRecordProjectionMapper.updateByRecordTypeAndRecordId(any(AccountRecordProjectionDO.class)))
|
||||
.thenReturn(1);
|
||||
|
||||
api.upsertRecordProjection(saveReq("publish", "publish-shared"));
|
||||
|
||||
// 命中已有行 -> update;回填 existing.id, tenantId 仍由上下文注入。
|
||||
verify(accountRecordProjectionMapper).updateByRecordTypeAndRecordId(
|
||||
org.mockito.ArgumentMatchers.<AccountRecordProjectionDO>argThat(projection ->
|
||||
Long.valueOf(42L).equals(projection.getId())
|
||||
&& Long.valueOf(200L).equals(projection.getTenantId())
|
||||
&& "publish".equals(projection.getRecordType())
|
||||
&& "publish-shared".equals(projection.getRecordId())));
|
||||
// BaseMapper.insert 有 insert(T)/insert(Collection<T>) 两个重载, never() 校验也要指定单实体重载消除歧义。
|
||||
verify(accountRecordProjectionMapper, never()).insert(any(AccountRecordProjectionDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_throwWhenUpdateRowsNotOne() {
|
||||
TenantContextHolder.setTenantId(200L);
|
||||
AccountRecordProjectionDO existing = new AccountRecordProjectionDO();
|
||||
existing.setId(42L);
|
||||
existing.setTenantId(200L);
|
||||
when(accountRecordProjectionMapper.selectByTenantIdAndRecordTypeAndRecordId(200L,
|
||||
"license", "license-200-1")).thenReturn(existing);
|
||||
// 更新影响行数 != 1 -> 必须显式抛 IllegalStateException, 防伪成功。
|
||||
when(accountRecordProjectionMapper.updateByRecordTypeAndRecordId(any(AccountRecordProjectionDO.class)))
|
||||
.thenReturn(0);
|
||||
|
||||
IllegalStateException exception = assertThrows(IllegalStateException.class,
|
||||
() -> api.upsertRecordProjection(saveReq("license", "license-200-1")));
|
||||
|
||||
assertTrue(exception.getMessage().contains("Account 投影更新失败"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_injectTenantFromContextNotDto() {
|
||||
// 即便调用方在别的租户上下文, 投影也只写当前上下文租户;DTO 本身不承载 tenantId。
|
||||
TenantContextHolder.setTenantId(300L);
|
||||
when(accountRecordProjectionMapper.selectByTenantIdAndRecordTypeAndRecordId(300L,
|
||||
"purchase", "purchase-300-9")).thenReturn(null);
|
||||
|
||||
api.upsertRecordProjection(saveReq("purchase", "purchase-300-9"));
|
||||
|
||||
verify(accountRecordProjectionMapper).selectByTenantIdAndRecordTypeAndRecordId(300L,
|
||||
"purchase", "purchase-300-9");
|
||||
verify(accountRecordProjectionMapper).insert(org.mockito.ArgumentMatchers.<AccountRecordProjectionDO>argThat(projection ->
|
||||
Long.valueOf(300L).equals(projection.getTenantId())));
|
||||
}
|
||||
|
||||
private static MuseAccountRecordProjectionSaveReqDTO saveReq(String recordType, String recordId) {
|
||||
return MuseAccountRecordProjectionSaveReqDTO.builder()
|
||||
.accountUserId(9001L)
|
||||
.recordType(recordType)
|
||||
.recordId(recordId)
|
||||
.sourceTable("muse_market_purchase")
|
||||
.sourceId(recordId)
|
||||
.sourceRevision("1")
|
||||
.sourceStatus("purchase".equals(recordType) ? "completed" : "active")
|
||||
.title("Flow Agent")
|
||||
.projectionSnapshot("{\"assetType\":\"agent\"}")
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@ -39,16 +39,11 @@ class BcBoundaryArchTest {
|
||||
/**
|
||||
* 已知违例豁免清单(整改期,单点、显式、可见)。详见 {@code .agents/rules/bc-boundaries.md §三}。
|
||||
*
|
||||
* <p>market 的投影 provider/service 仍直写 member 的 account-record-projection 表(经 member.dal Mapper);
|
||||
* 属**写路径**跨 BC 重构(需 member 暴露写端口 + DTO),风险较高,列为单点登记、待专项整改;
|
||||
* 整改后从本清单删除,门禁随即收紧。</p>
|
||||
* <p>当前为空:market→member.dal 写路径违例已整改(member 暴露写端口 {@code MuseAccountRecordProjectionApi} + DTO,
|
||||
* member-server 接管 upsert,market 5 类改消费端口、移除 member.dal 依赖),豁免删除即门禁收紧(收紧即验收)。
|
||||
* 新违例不在豁免内,一律红。</p>
|
||||
*/
|
||||
private static final Set<String> KNOWN_VIOLATION_EXEMPTIONS = Set.of(
|
||||
"cn.iocoder.muse.module.market.application.muse.MarketAccountProjectionProvider",
|
||||
"cn.iocoder.muse.module.market.application.muse.AdminMarketReviewServiceImpl",
|
||||
"cn.iocoder.muse.module.market.application.muse.MarketInstallServiceImpl",
|
||||
"cn.iocoder.muse.module.market.application.muse.MarketLicenseServiceImpl",
|
||||
"cn.iocoder.muse.module.market.application.muse.MarketPublishServiceImpl");
|
||||
private static final Set<String> KNOWN_VIOLATION_EXEMPTIONS = Set.of();
|
||||
|
||||
/**
|
||||
* 任一业务 BC 不得直连他域 DAL(通用规则,覆盖全部业务 BC 间方向)。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user