diff --git a/backend-java/pom.xml b/backend-java/pom.xml
index fb3ebbc7..f897b13e 100644
--- a/backend-java/pom.xml
+++ b/backend-java/pom.xml
@@ -150,6 +150,12 @@
spring-boot-starter-test
test
+
+ com.tngtech.archunit
+ archunit-junit5
+ 1.3.0
+ test
+
diff --git a/backend-java/src/test/java/com/nanri/aiimage/ArchitectureBoundaryTest.java b/backend-java/src/test/java/com/nanri/aiimage/ArchitectureBoundaryTest.java
new file mode 100644
index 00000000..423ef498
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/ArchitectureBoundaryTest.java
@@ -0,0 +1,108 @@
+package com.nanri.aiimage;
+
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.core.importer.ImportOption;
+import org.junit.jupiter.api.Test;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * task-210/211/212:模块边界 ArchUnit 规则(plan 13)。
+ *
+ * 已通过(零违规):任何 Controller 不得直接访问 Mapper 或外部 Client(211)。
+ * 债务防恶化(210/212,探针 2026-09-05):task 模块经 *ResultFileJobHandler 等依赖具体业务
+ * Service/Mapper 共 128 处、modules 切片存在 collectdata→dedupe→task 环;清零需把 Handler 实现
+ * 迁回业务模块等大重构。本文件冻结边界(不允许新增违规;存量≤128),消除债务留待专门重构。
+ */
+class ArchitectureBoundaryTest {
+
+ private static final int TASK_TO_BUSINESS_BASELINE = 128;
+
+ private static volatile JavaClasses cached;
+
+ private static JavaClasses app() {
+ if (cached == null) {
+ cached = new ClassFileImporter()
+ .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
+ .importPackages("com.nanri.aiimage.modules");
+ }
+ return cached;
+ }
+
+ @Test
+ void controllersDoNotAccessMapper() {
+ noClasses().that().resideInAPackage("..controller..")
+ .should().dependOnClassesThat().resideInAPackage("..mapper..")
+ .because("Controller 只经 Service 访问 Mapper(单向分层)")
+ .check(app());
+ }
+
+ @Test
+ void controllersDoNotDependOnExternalClient() {
+ noClasses().that().resideInAPackage("..controller..")
+ .should().dependOnClassesThat().resideInAPackage("..client..")
+ .because("外部 Client 细节由 Service 层收敛,Controller 不应感知")
+ .check(app());
+ }
+
+ @Test
+ void boundaryRulesCoverAllControllers() {
+ long controllers = app().stream()
+ .filter(c -> c.getPackageName().endsWith(".controller")).count();
+ assertTrue(controllers >= 10, "应覆盖全部 Controller,实际 " + controllers);
+ }
+
+ @Test
+ void taskToBusinessDependencyDoesNotGrow() {
+ // 冻结存量(探针 128):不允许 task→业务 依赖继续增加
+ int violations = countViolations(taskRule());
+ assertTrue(violations >= 1, "存在 task→业务 依赖(Handler SPI 承接点)");
+ assertTrue(violations <= TASK_TO_BUSINESS_BASELINE,
+ "task→业务 依赖不得超过基线 " + TASK_TO_BUSINESS_BASELINE + ",当前 " + violations
+ + ";新增依赖走 Handler SPI 或先消除存量");
+ }
+
+ @Test
+ void ruleFailuresCarryClearReason() {
+ // 规则附 because 说明,失败信息可定位(构造性校验:规则对象能生成可读描述)
+ assertTrue(taskRule().getDescription().contains("no classes that reside in a package"),
+ "规则描述应可读");
+ }
+
+ @Test
+ void spiHandlersAreTheSanctionedBridge() {
+ // 允许方向:task 模块仅 *ResultFileJobHandler(04 SPI 实现)可依赖业务 Service;
+ // 其余 task 类若依赖业务即属增长(由 taskToBusinessDependencyDoesNotGrow 守卫上限)。
+ JavaClasses classes = app();
+ long handlers = classes.stream()
+ .filter(c -> c.getSimpleName().endsWith("ResultFileJobHandler"))
+ .count();
+ assertTrue(handlers >= 10, "Handler SPI 实现应在 task 模块注册,实际 " + handlers);
+ }
+
+ private static com.tngtech.archunit.lang.ArchRule taskRule() {
+ return noClasses().that().resideInAPackage("..modules.task..")
+ .should().dependOnClassesThat().resideInAPackage(
+ "..modules.(similarasin|appearancepatent|deletebrand|publish|brand|collectdata|"
+ + "shopdatacrawl|imagevideo|shopmatch|productrisk|queryasin|withdraw|patroldelete)..")
+ .because("公共 task 模块不应依赖具体业务模块");
+ }
+
+ private static int countViolations(com.tngtech.archunit.lang.ArchRule rule) {
+ try {
+ rule.check(app());
+ return 0;
+ } catch (AssertionError e) {
+ String msg = e.getMessage();
+ long count = msg.lines()
+ .filter(l -> l.trim().startsWith("Constructor <")
+ || l.trim().startsWith("Field <")
+ || l.trim().startsWith("Method <")
+ || l.trim().startsWith("Class <"))
+ .count();
+ return (int) count;
+ }
+ }
+}