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:
lili 2026-06-14 06:22:09 -07:00
parent 2cff86b808
commit d38260fc51
20 changed files with 415 additions and 205 deletions

View File

@ -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 坐标收口可登记后续。
---
## 四、扩展方式

View File

@ -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`,不新增状态过程文档。

View File

@ -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)
```

View File

@ -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());
}
}

View File

@ -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]);
}
}

View File

@ -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());

View File

@ -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());
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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())

View File

@ -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)

View File

@ -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

View File

@ -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"))),

View File

@ -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())

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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 间方向)。