huangxiao 1 month ago
commit
2fb12c7e66
67 changed files with 3274 additions and 0 deletions
  1. 178 0
      pom.xml
  2. 32 0
      service-user-adapter/pom.xml
  3. 53 0
      service-user-adapter/src/main/java/com/hosea/service/adapter/web/TenantControllerImpl.java
  4. 60 0
      service-user-adapter/src/main/java/com/hosea/service/adapter/web/UserControllerImpl.java
  5. 31 0
      service-user-app/pom.xml
  6. 47 0
      service-user-app/src/main/java/com/hosea/service/app/tenant/TenantConversion.java
  7. 26 0
      service-user-app/src/main/java/com/hosea/service/app/tenant/TenantEventListener.java
  8. 84 0
      service-user-app/src/main/java/com/hosea/service/app/tenant/TenantService.java
  9. 51 0
      service-user-app/src/main/java/com/hosea/service/app/tenant/executor/TenantCmdExecute.java
  10. 18 0
      service-user-app/src/main/java/com/hosea/service/app/tenant/executor/TenantComplexQuery.java
  11. 55 0
      service-user-app/src/main/java/com/hosea/service/app/tenant/executor/TenantQueryExecute.java
  12. 47 0
      service-user-app/src/main/java/com/hosea/service/app/user/UserConversion.java
  13. 26 0
      service-user-app/src/main/java/com/hosea/service/app/user/UserEventListener.java
  14. 86 0
      service-user-app/src/main/java/com/hosea/service/app/user/UserService.java
  15. 56 0
      service-user-app/src/main/java/com/hosea/service/app/user/executor/UserCmdExecute.java
  16. 18 0
      service-user-app/src/main/java/com/hosea/service/app/user/executor/UserComplexQuery.java
  17. 44 0
      service-user-app/src/main/java/com/hosea/service/app/user/executor/UserQueryExecute.java
  18. 24 0
      service-user-client/pom.xml
  19. 46 0
      service-user-client/src/main/java/com/hosea/service/user/client/api/TenantApi.java
  20. 53 0
      service-user-client/src/main/java/com/hosea/service/user/client/api/UserApi.java
  21. 25 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/constant/TenantErrorCode.java
  22. 26 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/constant/UserErrorCode.java
  23. 23 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/data/TenantDTO.java
  24. 29 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/data/TenantUniqueDTO.java
  25. 32 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/data/UserDTO.java
  26. 30 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/data/UserUniqueDTO.java
  27. 26 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/event/TenantCreateEvent.java
  28. 26 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/event/UserCreateEvent.java
  29. 33 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/TenantAddCmd.java
  30. 33 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/TenantListPageQuery.java
  31. 34 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/TenantUpdateCmd.java
  32. 33 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserAddCmd.java
  33. 46 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserJoinTenantCmd.java
  34. 35 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserListByTenantPageQuery.java
  35. 33 0
      service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserUpdateCmd.java
  36. 28 0
      service-user-domain/pom.xml
  37. 38 0
      service-user-domain/src/main/java/com/hosea/service/domain/tenant/Tenant.java
  38. 62 0
      service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantDomainService.java
  39. 26 0
      service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantRepository.java
  40. 18 0
      service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantStatus.java
  41. 58 0
      service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantUnique.java
  42. 62 0
      service-user-domain/src/main/java/com/hosea/service/domain/user/User.java
  43. 114 0
      service-user-domain/src/main/java/com/hosea/service/domain/user/UserDomainService.java
  44. 47 0
      service-user-domain/src/main/java/com/hosea/service/domain/user/UserRepository.java
  45. 18 0
      service-user-domain/src/main/java/com/hosea/service/domain/user/UserStatus.java
  46. 59 0
      service-user-domain/src/main/java/com/hosea/service/domain/user/UserUnique.java
  47. 36 0
      service-user-infrastructure/pom.xml
  48. 19 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/config/MybatisPlusConfig.java
  49. 67 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantConversion.java
  50. 35 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantInfoPO.java
  51. 28 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantMapper.java
  52. 112 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantRepositoryImpl.java
  53. 73 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserConversion.java
  54. 122 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserInfoPO.java
  55. 53 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserMapper.java
  56. 82 0
      service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserRepositoryImpl.java
  57. 17 0
      service-user-infrastructure/src/main/resources/mapper/tenant-mapper.xml
  58. 21 0
      service-user-infrastructure/src/main/resources/mapper/user-mapper.xml
  59. 47 0
      start/pom.xml
  60. 30 0
      start/src/main/java/com/hosea/service/Application.java
  61. 35 0
      start/src/main/resources/application.yml
  62. 83 0
      start/src/test/java/com/hosea/service/user/client/api/TenantApiTest.java
  63. 78 0
      start/src/test/java/com/hosea/service/user/client/api/TestEndClean.java
  64. 109 0
      start/src/test/java/com/hosea/service/user/client/api/UserApiTest.java
  65. 33 0
      start/src/test/resources/application.yml
  66. 13 0
      start/src/test/resources/db/db.changelog-test.xml
  67. 152 0
      start/src/test/resources/db/init.sql

+ 178 - 0
pom.xml

@@ -0,0 +1,178 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>com.hosea.cloud.user</groupId>
+    <artifactId>service-user</artifactId>
+    <version>${revision}</version>
+    <packaging>pom</packaging>
+    <name>Service User ${version}</name>
+    <description>用户服务</description>
+
+    <properties>
+        <revision>1.0.0</revision>
+        <hosea-cloud.version>1.0.0</hosea-cloud.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <flatten-maven-plugin.version>1.3.0</flatten-maven-plugin.version>
+        <spring-boot.version>3.0.2</spring-boot.version>
+        <mysql.version>8.0.32</mysql.version>
+    </properties>
+
+    <modules>
+        <module>service-user-client</module>
+        <module>service-user-adapter</module>
+        <module>service-user-app</module>
+        <module>service-user-domain</module>
+        <module>service-user-infrastructure</module>
+        <module>start</module>
+    </modules>
+
+    <dependencies>
+        <!--region 微服务脚手架-->
+        <dependency>
+            <groupId>com.hosea.cloud</groupId>
+            <artifactId>hosea-cloud</artifactId>
+            <version>${hosea-cloud.version}</version>
+            <type>pom</type>
+        </dependency>
+        <!--endregion-->
+        <!--region 单元测试-->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!--endregion-->
+    </dependencies>
+
+    <dependencyManagement>
+        <dependencies>
+            <!--region 项目模块-->
+            <dependency>
+                <groupId>com.hosea.cloud.user</groupId>
+                <artifactId>service-user-client</artifactId>
+                <version>${revision}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.hosea.cloud.user</groupId>
+                <artifactId>service-user-adapter</artifactId>
+                <version>${revision}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.hosea.cloud.user</groupId>
+                <artifactId>service-user-app</artifactId>
+                <version>${revision}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.hosea.cloud.user</groupId>
+                <artifactId>service-user-domain</artifactId>
+                <version>${revision}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.hosea.cloud.user</groupId>
+                <artifactId>service-user-infrastructure</artifactId>
+                <version>${revision}</version>
+            </dependency>
+            <!--endregion-->
+            <!--region 微服务脚手架-->
+            <dependency>
+                <groupId>com.hosea.cloud</groupId>
+                <artifactId>hosea-cloud</artifactId>
+                <version>${hosea-cloud.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <!--endregion-->
+            <dependency>
+                <groupId>mysql</groupId>
+                <artifactId>mysql-connector-java</artifactId>
+                <version>${mysql.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <build>
+        <pluginManagement>
+            <plugins>
+                <!--region 打包-->
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-compiler-plugin</artifactId>
+                    <configuration>
+                        <source>17</source>
+                        <target>17</target>
+                        <encoding>${project.build.sourceEncoding}</encoding>
+                    </configuration>
+                </plugin>
+                <!--endregion-->
+                <!--region Spring Boot 可执行包-->
+                <plugin>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-maven-plugin</artifactId>
+                    <version>${spring-boot.version}</version>
+                    <executions>
+                        <execution>
+                            <goals>
+                                <goal>repackage</goal>
+                            </goals>
+                        </execution>
+                    </executions>
+                </plugin>
+                <!--endregion-->
+                <!--region 动态版本号-->
+                <plugin>
+                    <groupId>org.codehaus.mojo</groupId>
+                    <artifactId>flatten-maven-plugin</artifactId>
+                    <version>${flatten-maven-plugin.version}</version>
+                    <configuration>
+                        <!-- 配置扁平化后的 POM 文件的名称 -->
+                        <flattenedPomFilename>pom-xml-flattened</flattenedPomFilename>
+                        <!-- 是否更新原始 POM 文件 -->
+                        <updatePomFile>true</updatePomFile>
+                        <!-- 指定扁平化模式,resolveCiFriendliesOnly 表示仅解析 CI 友好的占位符 -->
+                        <flattenMode>resolveCiFriendliesOnly</flattenMode>
+                    </configuration>
+                    <executions>
+                        <!-- 定义插件的执行配置 -->
+                        <execution>
+                            <!-- 执行的唯一标识 -->
+                            <id>flatten</id>
+                            <!-- 指定插件绑定的生命周期阶段 -->
+                            <phase>process-resources</phase>
+                            <!-- 定义执行的目标 -->
+                            <goals>
+                                <!-- 执行扁平化操作 -->
+                                <goal>flatten</goal>
+                            </goals>
+                        </execution>
+                        <!-- 定义清理阶段的执行配置 -->
+                        <execution>
+                            <!-- 清理阶段的唯一标识 -->
+                            <id>flatten.clean</id>
+                            <!-- 指定清理阶段绑定的生命周期阶段 -->
+                            <phase>clean</phase>
+                            <!-- 定义清理阶段执行的目标 -->
+                            <goals>
+                                <!-- 执行清理操作 -->
+                                <goal>clean</goal>
+                            </goals>
+                        </execution>
+                    </executions>
+                </plugin>
+                <!--endregion-->
+            </plugins>
+        </pluginManagement>
+        <plugins>
+            <plugin>
+                <groupId>org.codehaus.mojo</groupId>
+                <artifactId>flatten-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 32 - 0
service-user-adapter/pom.xml

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.hosea.cloud.user</groupId>
+        <artifactId>service-user</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>service-user-adapter</artifactId>
+    <name>Adapter ${version}</name>
+    <description>
+        Adapter层,适配不同的请求方式,比如定时任务,MQTT事件等等
+        实现Client定义的接口,调用app对应的业务实现
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-client</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-app</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba.cola</groupId>
+            <artifactId>cola-component-catchlog-starter</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 53 - 0
service-user-adapter/src/main/java/com/hosea/service/adapter/web/TenantControllerImpl.java

@@ -0,0 +1,53 @@
+package com.hosea.service.adapter.web;
+
+import com.alibaba.cola.catchlog.CatchAndLog;
+import com.alibaba.cola.dto.PageResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.hosea.service.app.tenant.TenantService;
+import com.hosea.service.user.client.api.TenantApi;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.request.TenantAddCmd;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+import com.hosea.service.user.client.dto.request.TenantUpdateCmd;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 租户接口
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@CatchAndLog
+@RestController
+public class TenantControllerImpl implements TenantApi {
+    @Resource
+    private TenantService tenantService;
+
+    @Override
+    public SingleResponse<TenantDTO> get(TenantUniqueDTO unique) {
+        return tenantService.get(unique)
+                .map(SingleResponse::of)
+                .orElseGet(TenantErrorCode.NOT_EXIST::toSingleResponse);
+    }
+
+    @Override
+    public Response add(TenantAddCmd tenant) {
+        tenantService.add(tenant);
+        return Response.buildSuccess();
+    }
+
+    @Override
+    public Response update(TenantUpdateCmd tenant) {
+        tenantService.update(tenant);
+        return Response.buildSuccess();
+    }
+
+    @Override
+    public PageResponse<TenantDTO> list(TenantListPageQuery query) {
+        return tenantService.list(query);
+    }
+}

+ 60 - 0
service-user-adapter/src/main/java/com/hosea/service/adapter/web/UserControllerImpl.java

@@ -0,0 +1,60 @@
+package com.hosea.service.adapter.web;
+
+import com.alibaba.cola.catchlog.CatchAndLog;
+import com.alibaba.cola.dto.PageResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.hosea.cloud.web.login.JwtToken;
+import com.hosea.service.app.user.UserService;
+import com.hosea.service.user.client.api.UserApi;
+import com.hosea.service.user.client.dto.constant.UserErrorCode;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.request.UserAddCmd;
+import com.hosea.service.user.client.dto.request.UserJoinTenantCmd;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+import com.hosea.service.user.client.dto.request.UserUpdateCmd;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 用户接口
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@CatchAndLog
+@RestController
+public class UserControllerImpl implements UserApi {
+    @Resource
+    private UserService userService;
+
+    @Override
+    public SingleResponse<UserDTO> get(JwtToken token) {
+        return userService.of(token)
+                .map(SingleResponse::of)
+                .orElseGet(UserErrorCode.NOT_EXIST::toSingleResponse);
+    }
+
+    @Override
+    public Response add(UserAddCmd user) {
+        userService.add(user);
+        return Response.buildSuccess();
+    }
+
+    @Override
+    public Response update(UserUpdateCmd user) {
+        userService.update(user);
+        return Response.buildSuccess();
+    }
+
+    @Override
+    public Response join(UserJoinTenantCmd join) {
+        userService.join(join);
+        return Response.buildSuccess();
+    }
+
+    @Override
+    public PageResponse<UserDTO> list(UserListByTenantPageQuery query) {
+        return userService.list(query);
+    }
+}

+ 31 - 0
service-user-app/pom.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.hosea.cloud.user</groupId>
+        <artifactId>service-user</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>service-user-app</artifactId>
+    <name>Application ${version}</name>
+    <description>
+        Application层,调用Domain层,编织业务流程
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-domain</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.hosea.common</groupId>
+            <artifactId>common-mapstruct</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-tx</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 47 - 0
service-user-app/src/main/java/com/hosea/service/app/tenant/TenantConversion.java

@@ -0,0 +1,47 @@
+package com.hosea.service.app.tenant;
+
+import com.hosea.service.domain.tenant.Tenant;
+import com.hosea.service.domain.tenant.TenantUnique;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+/**
+ * 租户-DTO与领域实体转换器
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Mapper
+public interface TenantConversion {
+    /**
+     * 自动实现类
+     */
+    TenantConversion INSTANCE = Mappers.getMapper(TenantConversion.class);
+
+    /**
+     * 领域转DTO
+     */
+    @Mapping(target = "id", source = "unique.id")
+    @Mapping(target = "code", source = "unique.code")
+    TenantDTO toDto(Tenant user);
+
+    /**
+     * 领域转DTO
+     */
+    TenantUniqueDTO toDto(TenantUnique user);
+
+    /**
+     * DTO转领域
+     */
+    @Mapping(target = "unique.id", source = "id")
+    @Mapping(target = "unique.code", source = "code")
+    Tenant toDomain(TenantDTO user);
+
+    /**
+     * DTO转领域
+     */
+    TenantUnique toDomain(TenantUniqueDTO user);
+}

+ 26 - 0
service-user-app/src/main/java/com/hosea/service/app/tenant/TenantEventListener.java

@@ -0,0 +1,26 @@
+package com.hosea.service.app.tenant;
+
+import com.hosea.service.user.client.dto.event.TenantCreateEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 租户事件监听
+ *
+ * @author hosea
+ * @date 2025-07-30
+ */
+@Slf4j
+@Component
+public class TenantEventListener {
+    /**
+     * 监听创建事件
+     */
+    @Async
+    @TransactionalEventListener
+    public void createEvent(TenantCreateEvent event) {
+        log.info("租户已创建 {}", event);
+    }
+}

+ 84 - 0
service-user-app/src/main/java/com/hosea/service/app/tenant/TenantService.java

@@ -0,0 +1,84 @@
+package com.hosea.service.app.tenant;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.service.app.tenant.executor.TenantCmdExecute;
+import com.hosea.service.app.tenant.executor.TenantQueryExecute;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.request.TenantAddCmd;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+import com.hosea.service.user.client.dto.request.TenantUpdateCmd;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+/**
+ * 租户服务
+ * <p>
+ * <p>
+ * <b>职责:协调层</b>
+ * <p>
+ * 统一暴露业务能力入口(API粒度)
+ * <p>
+ * 事务边界控制(@Transactional)
+ * <p>
+ * 跨聚合协调(如租户创建时同步初始化权限)
+ * <p>
+ * 基础参数透传(不处理业务逻辑)
+ * <p>
+ * <p>
+ * <b>‌关键边界控制点‌</b>:
+ * <p>
+ * 流量方向:Controller → TenantService → (Cmd/Query)Execute → DomainService
+ * <p>
+ * 禁止反向调用:QueryExecute永远不能调用CmdExecute
+ * <p>
+ * 状态修改权限:只有CmdExecute可以修改领域对象状态
+ * <p>
+ * 数据可见性:QueryExecute可以直接访问Repository但CmdExecute必须通过DomainService
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@Service
+@Transactional
+public class TenantService {
+    @Resource
+    private TenantCmdExecute tenantCmd;
+    @Resource
+    private TenantQueryExecute tenantQuery;
+
+    /**
+     * 查
+     */
+    public Optional<TenantDTO> get(TenantUniqueDTO unique) {
+        return tenantQuery.of(unique);
+    }
+
+    /**
+     * 增
+     */
+    public void add(TenantAddCmd tenant) {
+        Assert.notNull(tenant, TenantErrorCode.NOT_NULL);
+        tenantCmd.add(tenant);
+    }
+
+    /**
+     * 删
+     */
+    public void update(TenantUpdateCmd tenant) {
+        Assert.notNull(tenant, TenantErrorCode.NOT_NULL);
+        tenantCmd.update(tenant);
+    }
+
+    /**
+     * 租户分页列表
+     */
+    public PageResponse<TenantDTO> list(TenantListPageQuery query) {
+        return tenantQuery.list(Optional.ofNullable(query).orElseGet(TenantListPageQuery::new));
+    }
+}

+ 51 - 0
service-user-app/src/main/java/com/hosea/service/app/tenant/executor/TenantCmdExecute.java

@@ -0,0 +1,51 @@
+package com.hosea.service.app.tenant.executor;
+
+import com.hosea.service.app.tenant.TenantConversion;
+import com.hosea.service.domain.tenant.Tenant;
+import com.hosea.service.domain.tenant.TenantDomainService;
+import com.hosea.service.user.client.dto.request.TenantAddCmd;
+import com.hosea.service.user.client.dto.request.TenantUpdateCmd;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+/**
+ * 租户-执行命令
+ * <p>
+ * <p>
+ * <b>职责:写操作处理器</b>
+ * <p>
+ * 命令参数基础校验(JSR-380注解校验)
+ * <p>
+ * 领域服务调用路由(如add/update对应不同领域方法)
+ * <p>
+ * DTO与领域对象转换(防腐层职责)
+ * <p>
+ * 操作日志记录等横切关注点
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Component
+public class TenantCmdExecute {
+    @Resource
+    private TenantDomainService tenantDomainService;
+
+    /**
+     * 增
+     */
+    public Tenant add(TenantAddCmd tenant) {
+        tenant.validation();
+        Tenant domain = TenantConversion.INSTANCE.toDomain(tenant.getTenant());
+        tenantDomainService.add(domain);
+        tenant.getTenant().setId(domain.getUnique().getId());
+        return domain;
+    }
+
+    /**
+     * 改
+     */
+    public void update(TenantUpdateCmd tenant) {
+        tenant.validation();
+        tenantDomainService.update(TenantConversion.INSTANCE.toDomain(tenant.getTenant()));
+    }
+}

+ 18 - 0
service-user-app/src/main/java/com/hosea/service/app/tenant/executor/TenantComplexQuery.java

@@ -0,0 +1,18 @@
+package com.hosea.service.app.tenant.executor;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+
+/**
+ * 租户-执行复杂查询
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+public interface TenantComplexQuery {
+    /**
+     * 租户分页列表
+     */
+    PageResponse<TenantDTO> list(TenantListPageQuery query);
+}

+ 55 - 0
service-user-app/src/main/java/com/hosea/service/app/tenant/executor/TenantQueryExecute.java

@@ -0,0 +1,55 @@
+package com.hosea.service.app.tenant.executor;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.hosea.service.app.tenant.TenantConversion;
+import com.hosea.service.domain.tenant.TenantDomainService;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+/**
+ * 租户-执行查询
+ * <p>
+ * <p>
+ * <b>职责:读操作处理器</b>
+ * <p>
+ * 查询参数预处理(分页/排序条件装配)
+ * <p>
+ * 简单查询 vs 复杂查询路由(如走Repository或ComplexQuery)
+ * <p>
+ * 结果数据组装(DTO/VO转换)
+ * <p>
+ * 缓存策略管理(Caffeine/Redis多级缓存)
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Component
+public class TenantQueryExecute {
+    @Resource
+    private TenantDomainService tenantDomainService;
+    @Resource
+    private TenantComplexQuery tenantComplexQuery;
+
+    /**
+     * 查
+     */
+    public Optional<TenantDTO> of(TenantUniqueDTO unique) {
+        return Optional.ofNullable(unique)
+                .map(TenantConversion.INSTANCE::toDomain)
+                .flatMap(tenantDomainService::of)
+                .map(TenantConversion.INSTANCE::toDto);
+    }
+
+    /**
+     * 租户分页列表
+     */
+    public PageResponse<TenantDTO> list(TenantListPageQuery query) {
+        query.validation();
+        return tenantComplexQuery.list(query);
+    }
+}

+ 47 - 0
service-user-app/src/main/java/com/hosea/service/app/user/UserConversion.java

@@ -0,0 +1,47 @@
+package com.hosea.service.app.user;
+
+import com.hosea.service.domain.user.User;
+import com.hosea.service.domain.user.UserUnique;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.data.UserUniqueDTO;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+/**
+ * 用户-DTO与领域实体转换器
+ *
+ * @author hosea
+ * @date 2025-07-19
+ */
+@Mapper
+public interface UserConversion {
+    /**
+     * 自动实现类
+     */
+    UserConversion INSTANCE = Mappers.getMapper(UserConversion.class);
+
+    /**
+     * 领域转DTO
+     */
+    @Mapping(target = "id", source = "unique.id")
+    @Mapping(target = "name", source = "unique.name")
+    UserDTO toDto(User user);
+
+    /**
+     * 领域转DTO
+     */
+    UserUniqueDTO toDto(UserUnique user);
+
+    /**
+     * DTO转领域
+     */
+    @Mapping(target = "unique.id", source = "id")
+    @Mapping(target = "unique.name", source = "name")
+    User toDomain(UserDTO user);
+
+    /**
+     * DTO转领域
+     */
+    UserUnique toDomain(UserUniqueDTO user);
+}

+ 26 - 0
service-user-app/src/main/java/com/hosea/service/app/user/UserEventListener.java

@@ -0,0 +1,26 @@
+package com.hosea.service.app.user;
+
+import com.hosea.service.user.client.dto.event.UserCreateEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 用户事件监听
+ *
+ * @author hosea
+ * @date 2025-07-30
+ */
+@Slf4j
+@Component
+public class UserEventListener {
+    /**
+     * 监听创建事件
+     */
+    @Async
+    @TransactionalEventListener
+    public void createEvent(UserCreateEvent event) {
+        log.info("用户已创建 {}", event);
+    }
+}

+ 86 - 0
service-user-app/src/main/java/com/hosea/service/app/user/UserService.java

@@ -0,0 +1,86 @@
+package com.hosea.service.app.user;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.cloud.web.login.JwtToken;
+import com.hosea.service.app.user.executor.UserCmdExecute;
+import com.hosea.service.app.user.executor.UserQueryExecute;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.constant.UserErrorCode;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.data.UserUniqueDTO;
+import com.hosea.service.user.client.dto.request.UserAddCmd;
+import com.hosea.service.user.client.dto.request.UserJoinTenantCmd;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+import com.hosea.service.user.client.dto.request.UserUpdateCmd;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+/**
+ * 用户服务
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@Service
+@Transactional
+public class UserService {
+    @Resource
+    private UserCmdExecute userCmd;
+    @Resource
+    private UserQueryExecute userQuery;
+
+    /**
+     * 查当前登录用户的信息
+     */
+    public Optional<UserDTO> of(JwtToken token) {
+        return Optional.ofNullable(token)
+                .map(JwtToken::getUser)
+                .map(user -> new UserUniqueDTO(null, user))
+                .flatMap(this::of);
+    }
+
+    /**
+     * 查用户
+     */
+
+    public Optional<UserDTO> of(UserUniqueDTO uniqueDTO) {
+        return userQuery.of(uniqueDTO);
+    }
+
+    /**
+     * 增
+     */
+    public void add(UserAddCmd user) throws BizException {
+        Assert.notNull(user, UserErrorCode.NOT_NULL);
+        userCmd.add(user);
+    }
+
+    /**
+     * 改
+     */
+    public void update(UserUpdateCmd user) throws BizException {
+        Assert.notNull(user, UserErrorCode.NOT_NULL);
+        userCmd.update(user);
+    }
+
+    /**
+     * 用户加入租户
+     */
+    public void join(UserJoinTenantCmd join) throws BizException {
+        Assert.notNull(join, UserErrorCode.NOT_NULL);
+        userCmd.join(join);
+    }
+
+    /**
+     * 根据租户查用户分页列表
+     */
+    public PageResponse<UserDTO> list(UserListByTenantPageQuery query) {
+        Assert.notNull(query, TenantErrorCode.NOT_NULL);
+        return userQuery.list(query);
+    }
+}

+ 56 - 0
service-user-app/src/main/java/com/hosea/service/app/user/executor/UserCmdExecute.java

@@ -0,0 +1,56 @@
+
+package com.hosea.service.app.user.executor;
+
+import com.alibaba.cola.exception.BizException;
+import com.hosea.service.app.tenant.TenantConversion;
+import com.hosea.service.app.user.UserConversion;
+import com.hosea.service.domain.tenant.TenantUnique;
+import com.hosea.service.domain.user.User;
+import com.hosea.service.domain.user.UserDomainService;
+import com.hosea.service.user.client.dto.request.UserAddCmd;
+import com.hosea.service.user.client.dto.request.UserJoinTenantCmd;
+import com.hosea.service.user.client.dto.request.UserUpdateCmd;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+/**
+ * 用户-执行命令
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@Component
+public class UserCmdExecute {
+    @Resource
+    private UserDomainService userDomainService;
+
+    /**
+     * 增
+     */
+    public User add(UserAddCmd user) throws BizException {
+        user.validation();
+        User domain = UserConversion.INSTANCE.toDomain(user.getUser());
+        userDomainService.add(domain);
+        user.getUser().setId(domain.getUnique().getId());
+        return domain;
+    }
+
+    /**
+     * 改
+     */
+    public void update(UserUpdateCmd user) throws BizException {
+        user.validation();
+        userDomainService.update(UserConversion.INSTANCE.toDomain(user.getUser()));
+    }
+
+    /**
+     * 用户加入租户
+     */
+    public void join(UserJoinTenantCmd join) throws BizException {
+        join.validation();
+        userDomainService.joinTenant(
+                UserConversion.INSTANCE.toDomain(join.getUser()),
+                join.getTenants().stream().map(TenantConversion.INSTANCE::toDomain).toArray(TenantUnique[]::new)
+        );
+    }
+}

+ 18 - 0
service-user-app/src/main/java/com/hosea/service/app/user/executor/UserComplexQuery.java

@@ -0,0 +1,18 @@
+package com.hosea.service.app.user.executor;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+
+/**
+ * 用户-执行复杂查询
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+public interface UserComplexQuery {
+    /**
+     * 根据租户查用户分页列表
+     */
+    PageResponse<UserDTO> list(UserListByTenantPageQuery query);
+}

+ 44 - 0
service-user-app/src/main/java/com/hosea/service/app/user/executor/UserQueryExecute.java

@@ -0,0 +1,44 @@
+package com.hosea.service.app.user.executor;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.hosea.service.app.user.UserConversion;
+import com.hosea.service.domain.user.UserDomainService;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.data.UserUniqueDTO;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+/**
+ * 用户-执行查询
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@Component
+public class UserQueryExecute {
+    @Resource
+    private UserDomainService userDomainService;
+    @Resource
+    private UserComplexQuery userComplexQuery;
+
+    /**
+     * 查用户
+     */
+    public Optional<UserDTO> of(UserUniqueDTO unique) {
+        return Optional.ofNullable(unique)
+                .map(UserConversion.INSTANCE::toDomain)
+                .flatMap(userDomainService::of)
+                .map(UserConversion.INSTANCE::toDto);
+    }
+
+    /**
+     * 根据租户查用户分页列表
+     */
+    public PageResponse<UserDTO> list(UserListByTenantPageQuery query) {
+        query.validation();
+        return userComplexQuery.list(query);
+    }
+}

+ 24 - 0
service-user-client/pom.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.hosea.cloud.user</groupId>
+        <artifactId>service-user</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>service-user-client</artifactId>
+    <name>Client ${version}</name>
+    <description>
+        Client层,定义DTO和业务请求接口
+        只引用基本的工具,不引用其它模块
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.hosea.cloud</groupId>
+            <artifactId>cloud-webmvc</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 46 - 0
service-user-client/src/main/java/com/hosea/service/user/client/api/TenantApi.java

@@ -0,0 +1,46 @@
+package com.hosea.service.user.client.api;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.request.TenantAddCmd;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+import com.hosea.service.user.client.dto.request.TenantUpdateCmd;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+/**
+ * 租户接口
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@RequestMapping("/tenant")
+public interface TenantApi {
+    /**
+     * 查单个
+     */
+    @GetMapping
+    SingleResponse<TenantDTO> get(TenantUniqueDTO unique);
+
+    /**
+     * 增加
+     */
+    @PostMapping
+    Response add(TenantAddCmd tenant);
+
+    /**
+     * 修改
+     */
+    @PostMapping("/update")
+    Response update(TenantUpdateCmd tenant);
+
+    /**
+     * 租户分页列表
+     */
+    @GetMapping("/list")
+    PageResponse<TenantDTO> list(TenantListPageQuery query);
+}

+ 53 - 0
service-user-client/src/main/java/com/hosea/service/user/client/api/UserApi.java

@@ -0,0 +1,53 @@
+package com.hosea.service.user.client.api;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.alibaba.cola.dto.Response;
+import com.alibaba.cola.dto.SingleResponse;
+import com.hosea.cloud.web.login.JwtToken;
+import com.hosea.service.user.client.dto.request.UserAddCmd;
+import com.hosea.service.user.client.dto.request.UserJoinTenantCmd;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+import com.hosea.service.user.client.dto.request.UserUpdateCmd;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+/**
+ * 用户接口
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+@RequestMapping("/user")
+public interface UserApi {
+    /**
+     * 查当前登录用户的信息
+     */
+    @GetMapping
+    SingleResponse<UserDTO> get(JwtToken token);
+
+    /**
+     * 增加
+     */
+    @PostMapping
+    Response add(UserAddCmd user);
+
+    /**
+     * 修改
+     */
+    @PostMapping("/update")
+    Response update(UserUpdateCmd user);
+
+    /**
+     * 用户加入租户
+     */
+    @GetMapping("/join")
+    Response join(UserJoinTenantCmd join);
+
+    /**
+     * 根据租户查用户分页列表
+     */
+    @GetMapping("/list")
+    PageResponse<UserDTO> list(UserListByTenantPageQuery query);
+}

+ 25 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/constant/TenantErrorCode.java

@@ -0,0 +1,25 @@
+package com.hosea.service.user.client.dto.constant;
+
+import com.hosea.cloud.web.exception.ErrorCode;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 异常定义
+ *
+ * @author hosea
+ * @date 2025-07-19
+ */
+@Getter
+@AllArgsConstructor
+public enum TenantErrorCode implements ErrorCode {
+    NOT_NULL("租户不能为空"),
+    NOT_NULL_CODE("租户编码不能为空"),
+    NOT_NULL_ID_OR_CODE("租户ID或租户编码不能为空"),
+    NOT_EXIST("租户不存在"),
+    ALREADY_EXISTS("租户已存在"),
+    ADD_FAILED("增加租户失败"),
+    UPDATE_FAILED("修改租户失败");
+
+    private final String errDesc;
+}

+ 26 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/constant/UserErrorCode.java

@@ -0,0 +1,26 @@
+package com.hosea.service.user.client.dto.constant;
+
+import com.hosea.cloud.web.exception.ErrorCode;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 异常定义
+ *
+ * @author hosea
+ * @date 2025-07-19
+ */
+@Getter
+@AllArgsConstructor
+public enum UserErrorCode implements ErrorCode {
+    NOT_NULL("用户不能为空"),
+    NOT_NULL_NAME("用户名不能为空"),
+    NOT_NULL_ID_OR_NAME("用户ID或用户名不能为空"),
+    NOT_EXIST("用户不存在"),
+    ALREADY_EXISTS("用户已存在"),
+    ADD_FAILED("增加用户失败"),
+    UPDATE_FAILED("修改用户失败"),
+    JOIN_TENANT_FAILED("用户加入租户失败");
+
+    private final String errDesc;
+}

+ 23 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/data/TenantDTO.java

@@ -0,0 +1,23 @@
+package com.hosea.service.user.client.dto.data;
+
+import lombok.*;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * 租户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@SuperBuilder
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class TenantDTO extends TenantUniqueDTO {
+    /**
+     * 名称
+     */
+    private String name;
+}

+ 29 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/data/TenantUniqueDTO.java

@@ -0,0 +1,29 @@
+package com.hosea.service.user.client.dto.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * 租户的唯一标识
+ * <p>
+ * 每个属性都有唯一性
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@SuperBuilder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TenantUniqueDTO {
+    /**
+     * ID
+     */
+    private String id;
+    /**
+     * 编码
+     */
+    private String code;
+}

+ 32 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/data/UserDTO.java

@@ -0,0 +1,32 @@
+package com.hosea.service.user.client.dto.data;
+
+import lombok.*;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * 用户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@SuperBuilder
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class UserDTO extends UserUniqueDTO {
+    /**
+     * 显示名称
+     */
+    private String displayName;
+    /**
+     * 昵称
+     */
+    private String nickName;
+    /**
+     * 工号
+     */
+    private String employeeNumber;
+}
+

+ 30 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/data/UserUniqueDTO.java

@@ -0,0 +1,30 @@
+package com.hosea.service.user.client.dto.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * 用户唯一标识
+ * <p>
+ * 每个属性都有唯一性
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@SuperBuilder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserUniqueDTO {
+    /**
+     * ID
+     */
+    private String id;
+    /**
+     * 用户名
+     */
+    private String name;
+}
+

+ 26 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/event/TenantCreateEvent.java

@@ -0,0 +1,26 @@
+package com.hosea.service.user.client.dto.event;
+
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import lombok.*;
+import lombok.experimental.SuperBuilder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 租户创建的事件
+ *
+ * @author hosea
+ * @date 2025-07-30
+ */
+@Data
+@SuperBuilder
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class TenantCreateEvent extends TenantUniqueDTO {
+    /**
+     * 创建时间
+     */
+    private LocalDateTime time;
+}

+ 26 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/event/UserCreateEvent.java

@@ -0,0 +1,26 @@
+package com.hosea.service.user.client.dto.event;
+
+import com.hosea.service.user.client.dto.data.UserUniqueDTO;
+import lombok.*;
+import lombok.experimental.SuperBuilder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户创建的事件
+ *
+ * @author hosea
+ * @date 2025-07-30
+ */
+@Data
+@SuperBuilder
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class UserCreateEvent extends UserUniqueDTO {
+    /**
+     * 创建时间
+     */
+    private LocalDateTime time;
+}

+ 33 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/TenantAddCmd.java

@@ -0,0 +1,33 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.Command;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.dto.Validation;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import lombok.*;
+
+/**
+ * 增加租户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class TenantAddCmd extends Command implements Validation {
+    /**
+     * 租户
+     */
+    private TenantDTO tenant;
+
+    @Override
+    public void validation() throws BizException {
+        Assert.notNull(tenant, TenantErrorCode.NOT_NULL);
+        Assert.notBlank(tenant.getCode(), TenantErrorCode.NOT_NULL_CODE);
+    }
+}

+ 33 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/TenantListPageQuery.java

@@ -0,0 +1,33 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.PageQuery;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.dto.Validation;
+import lombok.*;
+
+/**
+ * 租户分页列表
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class TenantListPageQuery extends PageQuery implements Validation {
+    /**
+     * 编码,模糊搜索
+     */
+    private String code;
+    /**
+     * 名称,模糊搜索
+     */
+    private String name;
+
+    @Override
+    public void validation() throws BizException {
+    }
+}

+ 34 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/TenantUpdateCmd.java

@@ -0,0 +1,34 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.Command;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.dto.Validation;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.common.utils.StrUtil;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import lombok.*;
+
+/**
+ * 修改租户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class TenantUpdateCmd extends Command implements Validation {
+    /**
+     * 租户
+     */
+    private TenantDTO tenant;
+
+    @Override
+    public void validation() throws BizException {
+        Assert.notNull(tenant, TenantErrorCode.NOT_NULL);
+        Assert.isFalse(StrUtil.isAllBlank(tenant.getCode(), tenant.getId()), TenantErrorCode.NOT_NULL_ID_OR_CODE);
+    }
+}

+ 33 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserAddCmd.java

@@ -0,0 +1,33 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.Command;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.dto.Validation;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.service.user.client.dto.constant.UserErrorCode;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import lombok.*;
+
+/**
+ * 增加用户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class UserAddCmd extends Command implements Validation {
+    /**
+     * 用户
+     */
+    private UserDTO user;
+
+    @Override
+    public void validation() throws BizException {
+        Assert.notNull(user, UserErrorCode.NOT_NULL);
+        Assert.notBlank(user.getName(), UserErrorCode.NOT_NULL_NAME);
+    }
+}

+ 46 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserJoinTenantCmd.java

@@ -0,0 +1,46 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.Command;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.dto.Validation;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.common.utils.StrUtil;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.constant.UserErrorCode;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.data.UserUniqueDTO;
+import lombok.*;
+
+import java.util.List;
+
+/**
+ * 用户加入租户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class UserJoinTenantCmd extends Command implements Validation {
+    /**
+     * 用户
+     */
+    private UserUniqueDTO user;
+    /**
+     * 租户
+     */
+    private List<TenantUniqueDTO> tenants;
+
+    @Override
+    public void validation() throws BizException {
+        Assert.notNull(user, UserErrorCode.NOT_NULL);
+        Assert.isFalse(StrUtil.isAllBlank(user.getName(), user.getId()), UserErrorCode.NOT_NULL_ID_OR_NAME);
+        Assert.notEmpty(tenants, TenantErrorCode.NOT_NULL);
+        for (TenantUniqueDTO tenant : tenants) {
+            Assert.isFalse(StrUtil.isAllBlank(tenant.getCode(), tenant.getId()), TenantErrorCode.NOT_NULL_ID_OR_CODE);
+        }
+    }
+}

+ 35 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserListByTenantPageQuery.java

@@ -0,0 +1,35 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.PageQuery;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.dto.Validation;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.common.utils.StrUtil;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import lombok.*;
+
+/**
+ * 根据租户查用户分页列表
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString(callSuper = true)
+@EqualsAndHashCode(callSuper = true)
+public class UserListByTenantPageQuery extends PageQuery implements Validation {
+    /**
+     * 租户
+     */
+    private TenantUniqueDTO tenant;
+
+    @Override
+    public void validation() throws BizException {
+        Assert.notNull(tenant, TenantErrorCode.NOT_NULL);
+        Assert.isFalse(StrUtil.isAllBlank(tenant.getCode(), tenant.getId()), TenantErrorCode.NOT_NULL_ID_OR_CODE);
+    }
+}

+ 33 - 0
service-user-client/src/main/java/com/hosea/service/user/client/dto/request/UserUpdateCmd.java

@@ -0,0 +1,33 @@
+package com.hosea.service.user.client.dto.request;
+
+import com.alibaba.cola.dto.Command;
+import com.hosea.cloud.web.dto.Validation;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.common.utils.StrUtil;
+import com.hosea.service.user.client.dto.constant.UserErrorCode;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import lombok.*;
+
+/**
+ * 修改用户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class UserUpdateCmd extends Command implements Validation {
+    /**
+     * 用户
+     */
+    private UserDTO user;
+
+    @Override
+    public void validation() {
+        Assert.notNull(user, UserErrorCode.NOT_NULL);
+        Assert.isFalse(StrUtil.isAllBlank(user.getName(), user.getId()), UserErrorCode.NOT_NULL_ID_OR_NAME);
+    }
+}

+ 28 - 0
service-user-domain/pom.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.hosea.cloud.user</groupId>
+        <artifactId>service-user</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>service-user-domain</artifactId>
+    <name>Domain ${version}</name>
+    <description>
+        Domain层,定义业务模型,定义资源接口,实现业务功能
+        只引用基本的工具,不引用其它模块。
+        事件:应用内,主要就是app和domain里自产自消的消息事件,对应实体就定义在domain里,如果有发送和接收对外消息事件,实体应该定义在client里
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-client</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba.cola</groupId>
+            <artifactId>cola-component-domain-starter</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 38 - 0
service-user-domain/src/main/java/com/hosea/service/domain/tenant/Tenant.java

@@ -0,0 +1,38 @@
+package com.hosea.service.domain.tenant;
+
+import lombok.*;
+
+import java.util.Objects;
+
+/**
+ * 租户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(of = "unique")
+public class Tenant {
+    /**
+     * 唯一标识
+     */
+    private TenantUnique unique;
+    /**
+     * 名称
+     */
+    private String name;
+    /**
+     * 状态
+     */
+    private TenantStatus status;
+
+    /**
+     * 状态正常
+     */
+    public boolean statusNormal() {
+        return Objects.equals(status, TenantStatus.Normal);
+    }
+}

+ 62 - 0
service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantDomainService.java

@@ -0,0 +1,62 @@
+package com.hosea.service.domain.tenant;
+
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.common.utils.StrUtil;
+import com.hosea.service.user.client.dto.constant.TenantErrorCode;
+import com.hosea.service.user.client.dto.event.TenantCreateEvent;
+import jakarta.annotation.Resource;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+/**
+ * 租户-领域服务
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Service
+public class TenantDomainService {
+    @Resource
+    private TenantRepository tenantRepository;
+    @Resource
+    private ApplicationEventPublisher eventPublisher;
+
+    /**
+     * 查正常租户
+     */
+    public Optional<Tenant> of(TenantUnique tenant) {
+        return Optional.ofNullable(tenant)
+                .flatMap(tenantRepository::of)
+                .filter(Tenant::statusNormal);
+    }
+
+    /**
+     * 增
+     */
+    public void add(Tenant tenant) throws BizException {
+        Assert.isFalse(of(tenant.getUnique()).isPresent(), TenantErrorCode.ALREADY_EXISTS);
+        tenant.setStatus(TenantStatus.Normal);
+        Assert.isTrue(tenantRepository.save(tenant), TenantErrorCode.ADD_FAILED);
+        eventPublisher.publishEvent(TenantCreateEvent.builder()
+                .id(tenant.getUnique().getId())
+                .code(tenant.getUnique().getCode())
+                .time(LocalDateTime.now())
+                .build()
+        );
+    }
+
+    /**
+     * 改
+     */
+    public void update(Tenant tenant) throws BizException {
+        Tenant old = of(tenant.getUnique()).orElseThrow(TenantErrorCode.NOT_EXIST::toBizException);
+        if (StrUtil.isEmpty(tenant.getUnique().getId())) {
+            tenant.getUnique().setId(old.getUnique().getId());
+        }
+        Assert.isTrue(tenantRepository.update(tenant), TenantErrorCode.UPDATE_FAILED);
+    }
+}

+ 26 - 0
service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantRepository.java

@@ -0,0 +1,26 @@
+package com.hosea.service.domain.tenant;
+
+import java.util.Optional;
+
+/**
+ * 租户-资源接口
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+public interface TenantRepository {
+    /**
+     * 查租户
+     */
+    Optional<Tenant> of(TenantUnique unique);
+
+    /**
+     * 增
+     */
+    boolean save(Tenant tenant);
+
+    /**
+     * 改
+     */
+    boolean update(Tenant tenant);
+}

+ 18 - 0
service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantStatus.java

@@ -0,0 +1,18 @@
+package com.hosea.service.domain.tenant;
+
+/**
+ * 租户状态
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+public enum TenantStatus {
+    /**
+     * 正常
+     */
+    Normal,
+    /**
+     * 禁用
+     */
+    Disable
+}

+ 58 - 0
service-user-domain/src/main/java/com/hosea/service/domain/tenant/TenantUnique.java

@@ -0,0 +1,58 @@
+package com.hosea.service.domain.tenant;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Objects;
+
+/**
+ * 租户的唯一标识
+ * <p>
+ * 每个属性都有唯一性
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TenantUnique {
+    /**
+     * ID
+     */
+    private String id;
+    /**
+     * 编码
+     */
+    private String code;
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        TenantUnique unique = (TenantUnique) o;
+        // 优先比较id字段
+        if (getId() != null) {
+            return Objects.equals(id, unique.getId());
+        }
+        // 如果id为空,则比较code字段
+        return code != null ? Objects.equals(code, unique.getCode()) : unique.getCode() == null;
+    }
+
+    @Override
+    public int hashCode() {
+        // 优先使用id字段生成hashCode
+        if (id != null) {
+            return id.hashCode();
+        }
+        // 如果id为空,则使用code字段生成hashCode
+        return code != null ? code.hashCode() : 0;
+    }
+}

+ 62 - 0
service-user-domain/src/main/java/com/hosea/service/domain/user/User.java

@@ -0,0 +1,62 @@
+package com.hosea.service.domain.user;
+
+import com.hosea.service.domain.tenant.TenantUnique;
+import lombok.*;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 用户
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(of = "unique")
+public class User {
+    /**
+     * 唯一标识
+     */
+    private UserUnique unique;
+    /**
+     * 显示名称
+     */
+    private String displayName;
+    /**
+     * 昵称
+     */
+    private String nickName;
+    /**
+     * 工号
+     */
+    private String employeeNumber;
+    /**
+     * 租户
+     */
+    private Set<TenantUnique> tenants = new HashSet<>();
+    /**
+     * 状态
+     */
+    private UserStatus status;
+
+    /**
+     * 加入租户
+     */
+    public void joinTenant(TenantUnique... tenant) {
+        tenants.addAll(List.of(tenant));
+    }
+
+    /**
+     * 状态正常
+     */
+    public boolean statusNormal() {
+        return Objects.equals(status, UserStatus.Normal);
+    }
+}
+

+ 114 - 0
service-user-domain/src/main/java/com/hosea/service/domain/user/UserDomainService.java

@@ -0,0 +1,114 @@
+package com.hosea.service.domain.user;
+
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.cola.exception.BizException;
+import com.hosea.cloud.web.exception.Assert;
+import com.hosea.service.domain.tenant.Tenant;
+import com.hosea.service.domain.tenant.TenantDomainService;
+import com.hosea.service.domain.tenant.TenantUnique;
+import com.hosea.service.user.client.dto.constant.UserErrorCode;
+import com.hosea.service.user.client.dto.event.UserCreateEvent;
+import jakarta.annotation.Resource;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * 用户-领域服务
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Service
+public class UserDomainService {
+    @Resource
+    private UserRepository userRepository;
+    @Resource
+    private TenantDomainService tenantDomainService;
+    @Resource
+    private ApplicationEventPublisher eventPublisher;
+
+    /**
+     * 查正常用户
+     */
+    public Optional<User> of(UserUnique user) {
+        return Optional.ofNullable(user)
+                .flatMap(userRepository::of)
+                .filter(User::statusNormal);
+    }
+
+    /**
+     * 查正常用户和用户的租户
+     */
+    public Optional<User> ofAndTenants(UserUnique user) {
+        return of(user).map(u -> {
+            u.setTenants(tenants(u.getUnique()).collect(Collectors.toSet()));
+            return u;
+        });
+    }
+
+    /**
+     * 查用户的所有正常租户
+     */
+    public Stream<TenantUnique> tenants(UserUnique user) {
+        return Optional.ofNullable(user)
+                .stream()
+                .flatMap(userRepository::tenants)
+                .filter(Tenant::statusNormal)
+                .map(Tenant::getUnique);
+    }
+
+    /**
+     * 增
+     */
+    public void add(User user) throws BizException {
+        Assert.isFalse(of(user.getUnique()).isPresent(), UserErrorCode.ALREADY_EXISTS);
+        user.setStatus(UserStatus.Normal);
+        Assert.isTrue(userRepository.save(user), UserErrorCode.ADD_FAILED);
+        eventPublisher.publishEvent(UserCreateEvent.builder()
+                .id(user.getUnique().getId())
+                .name(user.getUnique().getName())
+                .time(LocalDateTime.now())
+                .build()
+        );
+    }
+
+    /**
+     * 改
+     */
+    public void update(User user) throws BizException {
+        User old = of(user.getUnique()).orElseThrow(UserErrorCode.NOT_EXIST::toBizException);
+        if (StrUtil.isBlank(user.getUnique().getId())) {
+            user.getUnique().setId(old.getUnique().getId());
+        }
+        Assert.isTrue(userRepository.update(user), UserErrorCode.UPDATE_FAILED);
+    }
+
+    /**
+     * 用户加入租户
+     * <p>
+     * 先清空,再全部插入
+     */
+    public void joinTenant(UserUnique userUnique, TenantUnique... tenant) throws BizException {
+        // 先查到用户
+        User user = ofAndTenants(userUnique).orElseThrow(UserErrorCode.NOT_EXIST::toBizException);
+        // 先删再说
+        userRepository.cleanAllTenant(user.getUnique());
+        if (ArrayUtil.isEmpty(tenant)) {
+            return;
+        }
+        // 加入租户
+        for (TenantUnique tenantUnique : tenant) {
+            tenantDomainService.of(tenantUnique).map(Tenant::getUnique).ifPresent(user::joinTenant);
+        }
+        Assert.isTrue(userRepository.saveAllTenant(
+                user.getUnique().getId(),
+                user.getTenants().stream().map(TenantUnique::getId).collect(Collectors.toSet())
+        ), UserErrorCode.JOIN_TENANT_FAILED);
+    }
+}

+ 47 - 0
service-user-domain/src/main/java/com/hosea/service/domain/user/UserRepository.java

@@ -0,0 +1,47 @@
+package com.hosea.service.domain.user;
+
+import com.hosea.service.domain.tenant.Tenant;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+
+/**
+ * 用户-资源接口
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+public interface UserRepository {
+    /**
+     * 查用户
+     * <p>
+     * 不需查出关联的租户
+     */
+    Optional<User> of(UserUnique unique);
+
+    /**
+     * 查用户的所有租户
+     */
+    Stream<Tenant> tenants(UserUnique unique);
+
+    /**
+     * 增
+     */
+    boolean save(User user);
+
+    /**
+     * 改
+     */
+    boolean update(User user);
+
+    /**
+     * 清除所有租户
+     */
+    void cleanAllTenant(UserUnique unique);
+
+    /**
+     * 保存所有租户
+     */
+    boolean saveAllTenant(String user, Set<String> tenants);
+}

+ 18 - 0
service-user-domain/src/main/java/com/hosea/service/domain/user/UserStatus.java

@@ -0,0 +1,18 @@
+package com.hosea.service.domain.user;
+
+/**
+ * 用户状态
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+public enum UserStatus {
+    /**
+     * 正常
+     */
+    Normal,
+    /**
+     * 禁用
+     */
+    Disable
+}

+ 59 - 0
service-user-domain/src/main/java/com/hosea/service/domain/user/UserUnique.java

@@ -0,0 +1,59 @@
+package com.hosea.service.domain.user;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Objects;
+
+/**
+ * 用户唯一标识
+ * <p>
+ * 每个属性都有唯一性
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserUnique {
+    /**
+     * ID,非空时优先对比
+     */
+    private String id;
+    /**
+     * 用户名
+     */
+    private String name;
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        UserUnique unique = (UserUnique) o;
+        // 优先比较id字段
+        if (getId() != null) {
+            return Objects.equals(id, unique.getId());
+        }
+        // 如果id为空,则比较name字段
+        return name != null ? Objects.equals(name, unique.getName()) : unique.getName() == null;
+    }
+
+    @Override
+    public int hashCode() {
+        // 优先使用id字段生成hashCode
+        if (id != null) {
+            return id.hashCode();
+        }
+        // 如果id为空,则使用name字段生成hashCode
+        return name != null ? name.hashCode() : 0;
+    }
+}
+

+ 36 - 0
service-user-infrastructure/pom.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.hosea.cloud.user</groupId>
+        <artifactId>service-user</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>service-user-infrastructure</artifactId>
+    <name>Infrastructure ${version}</name>
+    <description>
+        Infrastructure层,基础设施,对接数据库、缓存等等各种东西
+        实现领域层的资源接口,操作具体的资源
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-app</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.xerial</groupId>
+            <artifactId>sqlite-jdbc</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.hosea.cloud</groupId>
+            <artifactId>cloud-mybatis-plus</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 19 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/config/MybatisPlusConfig.java

@@ -0,0 +1,19 @@
+package com.hosea.service.infra.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class MybatisPlusConfig {
+
+    @Bean
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+        // 添加分页插件
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+        return interceptor;
+    }
+}

+ 67 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantConversion.java

@@ -0,0 +1,67 @@
+package com.hosea.service.infra.tenant;
+
+import com.hosea.service.domain.tenant.Tenant;
+import com.hosea.service.domain.tenant.TenantStatus;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import org.mapstruct.AfterMapping;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.factory.Mappers;
+
+/**
+ * 租户-PO与领域实体转换器
+ *
+ * @author hosea
+ * @date 2025-07-19
+ */
+@Mapper
+public interface TenantConversion {
+    /**
+     * 自动实现类
+     */
+    TenantConversion INSTANCE = Mappers.getMapper(TenantConversion.class);
+
+    /**
+     * 领域转PO
+     */
+    @Mapping(target = "id", source = "unique.id")
+    @Mapping(target = "groupcode", source = "unique.code")
+    @Mapping(target = "groupname", source = "name")
+    @Mapping(target = "status", ignore = true)
+    TenantInfoPO toPo(Tenant tenant);
+
+    /**
+     * 领域转PO-映射后的自定义转换
+     */
+    @AfterMapping
+    default void toPo(Tenant tenant, @MappingTarget TenantInfoPO po) {
+        if (TenantStatus.Normal.equals(tenant.getStatus())) {
+            po.setStatus(1L);
+        }
+    }
+
+    /**
+     * PO转领域
+     */
+    @Mapping(target = "unique.id", source = "id")
+    @Mapping(target = "unique.code", source = "groupcode")
+    @Mapping(target = "name", source = "groupname")
+    @Mapping(target = "status", ignore = true)
+    Tenant toDomain(TenantInfoPO po);
+
+    /**
+     * PO转领域-映射后的自定义转换
+     */
+    @AfterMapping
+    default void toDomain(TenantInfoPO po, @MappingTarget Tenant tenant) {
+        tenant.setStatus(po.getStatus() == 1L ? TenantStatus.Normal : TenantStatus.Disable);
+    }
+
+    /**
+     * PO转DTO
+     */
+    @Mapping(target = "code", source = "groupcode")
+    @Mapping(target = "name", source = "groupname")
+    TenantDTO toDto(TenantInfoPO po);
+}

+ 35 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantInfoPO.java

@@ -0,0 +1,35 @@
+package com.hosea.service.infra.tenant;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 租户-自动生成的数据库实体类
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Data
+@TableName("mxk_groups")
+public class TenantInfoPO {
+    @TableId
+    private String id;
+    private String groupcode;
+    private String groupname;
+    private String category;
+    private String filters;
+    private String orgidslist;
+    private String resumetime;
+    private String suspendtime;
+    private Long status;
+    private String createdby;
+    private Long isdefault;
+    private LocalDateTime createddate;
+    private String modifiedby;
+    private LocalDateTime modifieddate;
+    private String description;
+    private String instid;
+}

+ 28 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantMapper.java

@@ -0,0 +1,28 @@
+package com.hosea.service.infra.tenant;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.hosea.service.domain.tenant.TenantUnique;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 租户-数据库操作
+ *
+ * @author hx
+ * @date 2025-07-23
+ */
+@Mapper
+public interface TenantMapper extends BaseMapper<TenantInfoPO> {
+    /**
+     * 查唯一的数据
+     */
+    @Select("select * from mxk_groups where ID=#{id} or GROUPCODE=#{code}")
+    TenantInfoPO one(TenantUnique unique);
+
+    /**
+     * 分页列表
+     */
+    IPage<TenantInfoPO> list(IPage<TenantInfoPO> page, @Param("name") String name, @Param("code") String code);
+}

+ 112 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/tenant/TenantRepositoryImpl.java

@@ -0,0 +1,112 @@
+package com.hosea.service.infra.tenant;
+
+import com.alibaba.cola.dto.PageQuery;
+import com.alibaba.cola.dto.PageResponse;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.metadata.OrderItem;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.hosea.common.utils.CollUtil;
+import com.hosea.common.utils.StrUtil;
+import com.hosea.service.app.tenant.executor.TenantComplexQuery;
+import com.hosea.service.domain.tenant.Tenant;
+import com.hosea.service.domain.tenant.TenantRepository;
+import com.hosea.service.domain.tenant.TenantUnique;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+/**
+ * 租户-资源接口实现
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Component
+public class TenantRepositoryImpl implements TenantRepository, TenantComplexQuery {
+    @Resource
+    private TenantMapper tenantMapper;
+
+    @Override
+    public Optional<Tenant> of(TenantUnique unique) {
+        return Optional.ofNullable(tenantMapper.one(unique))
+                .map(TenantConversion.INSTANCE::toDomain);
+    }
+
+    @Override
+    public boolean save(Tenant tenant) {
+        TenantInfoPO po = TenantConversion.INSTANCE.toPo(tenant);
+        // 兼容maxkey的非空逻辑
+        po.setInstid("1");
+        if (tenantMapper.insert(po) > 0) {
+            tenant.getUnique().setId(po.getId());
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public boolean update(Tenant tenant) {
+        return tenantMapper.updateById(TenantConversion.INSTANCE.toPo(tenant)) > 0;
+    }
+
+    @Override
+    public PageResponse<TenantDTO> list(TenantListPageQuery query) {
+        return page(query,
+                (pageQuery, page) -> tenantMapper.list(page, pageQuery.getName(), pageQuery.getCode()),
+                TenantConversion.INSTANCE::toDto
+        );
+    }
+
+    /**
+     * 执行分页查询
+     *
+     * @param query      查询条件
+     * @param mapper     执行的SQL
+     * @param conversion 结果转换
+     */
+    public static <T, R, P extends PageQuery> PageResponse<R> page(P query, BiFunction<P, IPage<T>, IPage<T>> mapper, Function<? super T, R> conversion) {
+        return pageToPageResponse(mapper.apply(query, pageQueryToPage(query, new Page<>())), conversion);
+    }
+
+    /**
+     * 把COLA的分页转MyBatis的分页
+     */
+    public static <T> IPage<T> pageQueryToPage(PageQuery query, Page<T> page) {
+        page.setSize(query.getPageSize());
+        page.setCurrent(query.getPageIndex());
+        Optional.ofNullable(query.getOrderBy())
+                .filter(StrUtil::isNotBlank)
+                .ifPresent(orderBy -> {
+                    if (StrUtil.equalsIgnoreCase(query.getOrderDirection(), PageQuery.DESC)) {
+                        page.addOrder(OrderItem.desc(orderBy));
+                    } else {
+                        page.addOrder(OrderItem.asc(orderBy));
+                    }
+                });
+        return page;
+    }
+
+    /**
+     * MyBatis的分页结果转COLA的结果
+     */
+    public static <T, R> PageResponse<R> pageToPageResponse(IPage<T> page, Function<? super T, R> conversion) {
+        PageResponse<R> resp = new PageResponse<>();
+        resp.setSuccess(true);
+        resp.setPageIndex((int) page.getCurrent());
+        resp.setPageSize((int) page.getSize());
+        resp.setTotalCount((int) page.getTotal());
+        resp.setData(Optional.ofNullable(page.getRecords())
+                .filter(CollUtil::isNotEmpty)
+                .stream()
+                .flatMap(Collection::stream)
+                .map(conversion)
+                .toList());
+        return resp;
+    }
+}

+ 73 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserConversion.java

@@ -0,0 +1,73 @@
+package com.hosea.service.infra.user;
+
+import com.hosea.service.domain.user.User;
+import com.hosea.service.domain.user.UserStatus;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import org.mapstruct.AfterMapping;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.factory.Mappers;
+
+/**
+ * 用户-PO与领域实体转换器
+ *
+ * @author hosea
+ * @date 2025-07-19
+ */
+@Mapper
+public interface UserConversion {
+    /**
+     * 自动实现类
+     */
+    UserConversion INSTANCE = Mappers.getMapper(UserConversion.class);
+
+    /**
+     * 领域转PO
+     */
+    @Mapping(target = "id", source = "unique.id")
+    @Mapping(target = "username", source = "unique.name")
+    @Mapping(target = "nickname", source = "nickName")
+    @Mapping(target = "displayname", source = "displayName")
+    @Mapping(target = "employeenumber", source = "employeeNumber")
+    @Mapping(target = "status", ignore = true)
+    UserInfoPO toPo(User user);
+
+    /**
+     * 领域转PO-映射后的自定义转换
+     */
+    @AfterMapping
+    default void toPo(User user, @MappingTarget UserInfoPO po) {
+        if (UserStatus.Normal.equals(user.getStatus())) {
+            po.setStatus(1L);
+        }
+    }
+
+    /**
+     * PO转领域
+     */
+    @Mapping(target = "unique.id", source = "id")
+    @Mapping(target = "unique.name", source = "username")
+    @Mapping(target = "nickName", source = "nickname")
+    @Mapping(target = "displayName", source = "displayname")
+    @Mapping(target = "employeeNumber", source = "employeenumber")
+    @Mapping(target = "status", ignore = true)
+    User toDomain(UserInfoPO po);
+
+    /**
+     * PO转领域-映射后的自定义转换
+     */
+    @AfterMapping
+    default void toDomain(UserInfoPO po, @MappingTarget User user) {
+        user.setStatus(po.getStatus() == 1L ? UserStatus.Normal : UserStatus.Disable);
+    }
+
+    /**
+     * PO转DTO
+     */
+    @Mapping(target = "name", source = "username")
+    @Mapping(target = "nickName", source = "nickname")
+    @Mapping(target = "displayName", source = "displayname")
+    @Mapping(target = "employeeNumber", source = "employeenumber")
+    UserDTO toDto(UserInfoPO po);
+}

+ 122 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserInfoPO.java

@@ -0,0 +1,122 @@
+package com.hosea.service.infra.user;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户-自动生成的数据库实体类
+ *
+ * @author hosea
+ * @date 2025-07-23
+ */
+@Data
+@TableName("mxk_userinfo")
+public class UserInfoPO {
+    @TableId
+    private String id;
+    private String username;
+    private String password;
+    private String decipherable;
+    private Long authntype;
+    private String mobile;
+    private String mobileverified;
+    private String email;
+    private Long emailverified;
+    private String displayname;
+    private String nickname;
+    private String picture;
+    private String timezone;
+    private String locale;
+    private String preferredlanguage;
+    private String passwordquestion;
+    private String passwordanswer;
+    private Long apploginauthntype;
+    private String apploginpassword;
+    private String protectedapps;
+    private String theme;
+    private Long gridlist;
+    private Long logincount;
+    private Long online;
+    private Long status;
+    private Long islocked;
+    private LocalDateTime unlocktime;
+    private String lastloginip;
+    private LocalDateTime lastlogintime;
+    private LocalDateTime lastlogofftime;
+    private LocalDateTime badpasswordtime;
+    private Long badpasswordcount;
+    private LocalDateTime passwordlastsettime;
+    private Long passwordsettype;
+    private String sharedsecret;
+    private String sharedcounter;
+    private String usertype;
+    private String userstate;
+    private String employeenumber;
+    private String windowsaccount;
+    private String division;
+    private String costcenter;
+    private String organization;
+    private String departmentid;
+    private String department;
+    private String jobtitle;
+    private String joblevel;
+    private String managerid;
+    private String manager;
+    private String assistantid;
+    private String assistant;
+    private String entrydate;
+    private String startworkdate;
+    private String quitdate;
+    private Long sortindex;
+    private String workemail;
+    private String workphonenumber;
+    private String workcountry;
+    private String workregion;
+    private String worklocality;
+    private String workstreetaddress;
+    private String workaddressformatted;
+    private String workpostalcode;
+    private String workfax;
+    private String workofficename;
+    private String givenname;
+    private String middlename;
+    private String familyname;
+    private String honorificprefix;
+    private String honorificsuffix;
+    private String formattedname;
+    private Long idtype;
+    private String idcardno;
+    private String education;
+    private String graduatefrom;
+    private String graduatedate;
+    private Long married;
+    private String birthdate;
+    private String namezhspell;
+    private String namezhshortspell;
+    private Long gender;
+    private String website;
+    private Long weixinfollow;
+    private String defineim;
+    private String homeemail;
+    private String homephonenumber;
+    private String homecountry;
+    private String homeregion;
+    private String homelocality;
+    private String homestreetaddress;
+    private String homeaddressformatted;
+    private String homepostalcode;
+    private String homefax;
+    private String extraattribute;
+    private String createdby;
+    private LocalDateTime createddate;
+    private String modifiedby;
+    private LocalDateTime modifieddate;
+    private String description;
+    private String ldapdn;
+    private String instid;
+    private String regionhistory;
+    private String passwordhistory;
+}

+ 53 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserMapper.java

@@ -0,0 +1,53 @@
+package com.hosea.service.infra.user;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.hosea.service.domain.user.UserUnique;
+import com.hosea.service.infra.tenant.TenantInfoPO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 用户-数据库操作
+ *
+ * @author hx
+ * @date 2025-07-23
+ */
+@Mapper
+public interface UserMapper extends BaseMapper<UserInfoPO> {
+    /**
+     * 查唯一的数据
+     */
+    @Select("select * from mxk_userinfo where ID=#{id} or USERNAME=#{name} ")
+    UserInfoPO one(UserUnique unique);
+
+    /**
+     * 查用户的所有租户
+     */
+    @Select("select j.* from mxk_groups j join mxk_group_member gm on j.ID=gm.GROUPID join mxk_userinfo u on u.ID=gm.MEMBERID where u.ID=#{id} or u.USERNAME=#{name}")
+    List<TenantInfoPO> tenants(UserUnique unique);
+
+    /**
+     * 清空用户的租户
+     */
+    @Delete("DELETE gm FROM mxk_group_member gm JOIN mxk_userinfo u ON u.ID = gm.MEMBERID WHERE u.ID = #{id} OR u.USERNAME = #{name}")
+    void cleanTenant(UserUnique unique);
+
+    /**
+     * 批量保存用户的租户
+     */
+    Integer saveTenant(@Param("user") String user, @Param("tenants") Set<String> tenants);
+
+    /**
+     * 查分页列表
+     * <p>
+     * 根据租户查用户
+     */
+    IPage<UserInfoPO> list(IPage<UserInfoPO> page, @Param("tenant") TenantUniqueDTO tenant);
+}

+ 82 - 0
service-user-infrastructure/src/main/java/com/hosea/service/infra/user/UserRepositoryImpl.java

@@ -0,0 +1,82 @@
+package com.hosea.service.infra.user;
+
+import com.alibaba.cola.dto.PageResponse;
+import com.hosea.service.app.user.executor.UserComplexQuery;
+import com.hosea.service.domain.tenant.Tenant;
+import com.hosea.service.domain.user.User;
+import com.hosea.service.domain.user.UserRepository;
+import com.hosea.service.domain.user.UserUnique;
+import com.hosea.service.infra.tenant.TenantConversion;
+import com.hosea.service.infra.tenant.TenantRepositoryImpl;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+
+/**
+ * 用户-资源接口实现
+ *
+ * @author hosea
+ * @date 2025-07-18
+ */
+@Component
+public class UserRepositoryImpl implements UserRepository, UserComplexQuery {
+    @Resource
+    private UserMapper userMapper;
+
+    @Override
+    public Optional<User> of(UserUnique unique) {
+        return Optional.ofNullable(userMapper.one(unique))
+                .map(UserConversion.INSTANCE::toDomain);
+    }
+
+    @Override
+    public Stream<Tenant> tenants(UserUnique unique) {
+        return Optional.ofNullable(userMapper.tenants(unique))
+                .stream()
+                .flatMap(Collection::stream)
+                .map(TenantConversion.INSTANCE::toDomain);
+    }
+
+    @Override
+    public boolean save(User user) {
+        UserInfoPO po = UserConversion.INSTANCE.toPo(user);
+        // 兼容maxkey的非空逻辑
+        po.setInstid("1");
+        po.setPassword("");
+        po.setDecipherable("");
+        if (userMapper.insert(po) > 0) {
+            user.getUnique().setId(po.getId());
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public boolean update(User user) {
+        return userMapper.updateById(UserConversion.INSTANCE.toPo(user)) > 0;
+    }
+
+    @Override
+    public void cleanAllTenant(UserUnique unique) {
+        userMapper.cleanTenant(unique);
+    }
+
+    @Override
+    public boolean saveAllTenant(String user, Set<String> tenants) {
+        return userMapper.saveTenant(user, tenants) > 0;
+    }
+
+    @Override
+    public PageResponse<UserDTO> list(UserListByTenantPageQuery query) {
+        return TenantRepositoryImpl.page(query,
+                (pageQuery, page) -> userMapper.list(page, pageQuery.getTenant()),
+                UserConversion.INSTANCE::toDto
+        );
+    }
+}

+ 17 - 0
service-user-infrastructure/src/main/resources/mapper/tenant-mapper.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.hosea.service.infra.tenant.TenantMapper">
+    <select id="list" resultType="com.hosea.service.infra.tenant.TenantInfoPO">
+        select * from mxk_groups
+        <where>
+            <if test="code != null and code != ''">
+                GROUPCODE LIKE CONCAT('%', #{code}, '%')
+            </if>
+            <if test="name != null and name != ''">
+                OR GROUPNAME LIKE CONCAT('%', #{name}, '%')
+            </if>
+        </where>
+    </select>
+</mapper>

+ 21 - 0
service-user-infrastructure/src/main/resources/mapper/user-mapper.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.hosea.service.infra.user.UserMapper">
+    <insert id="saveTenant" parameterType="map">
+        INSERT INTO mxk_group_member (ID,GROUPID,MEMBERID,TYPE,INSTID) VALUES
+        <foreach collection="tenants" item="t" separator=",">
+            (REPLACE(UUID(),'-',''),#{t},#{user},'USER','1')
+        </foreach>
+        ON DUPLICATE KEY UPDATE CREATEDDATE = CURRENT_TIMESTAMP
+    </insert>
+    <select id="list" resultType="com.hosea.service.infra.user.UserInfoPO">
+        select u.*
+        from mxk_userinfo u
+                 join mxk_group_member gm on gm.MEMBERID = u.ID
+                 join mxk_groups g on g.ID = gm.GROUPID
+        where g.ID = #{tenant.id}
+           or g.GROUPCODE = #{tenant.code}
+    </select>
+</mapper>

+ 47 - 0
start/pom.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.hosea.cloud.user</groupId>
+        <artifactId>service-user</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>start</artifactId>
+    <name>Start ${version}</name>
+    <description>启动</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-adapter</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.hosea.cloud.user</groupId>
+            <artifactId>service-user-infrastructure</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.hosea.cloud</groupId>
+            <artifactId>cloud-nacos</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.xerial</groupId>
+            <artifactId>sqlite-jdbc</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 30 - 0
start/src/main/java/com/hosea/service/Application.java

@@ -0,0 +1,30 @@
+package com.hosea.service;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * 启动类
+ *
+ * @author hosea
+ * @date 2025-06-23
+ */
+// 开启定时任务
+@EnableScheduling
+// 扫描@Mapper的接口包,都在基础设施层
+@MapperScan(value = "com.hosea.service.infra")
+@SpringBootApplication(
+        scanBasePackages = {
+                // 扫描项目主路径
+                "com.hosea",
+                // 扫描cola
+                "com.alibaba.cola"
+        }
+)
+public class Application {
+    public static void main(String[] args) {
+        SpringApplication.run(Application.class, args);
+    }
+}

+ 35 - 0
start/src/main/resources/application.yml

@@ -0,0 +1,35 @@
+spring.config.import:
+  - classpath:web.yml
+  - classpath:mybatis-plus.yml
+  - classpath:nacos.yml
+---
+#---表示‌文档分隔符‌,在Spring Boot中后续文档块的配置会覆盖前面文档块的同名配置
+server.port: 10001
+spring.profiles.active: dev
+spring.application.name: user
+
+spring:
+  cloud:
+    nacos:
+      discovery:
+        server-addr: 127.0.0.1:8848
+        namespace: 035dcaf6-f934-4a62-b5cf-83e6fe333fc8
+      config:
+        server-addr: 127.0.0.1:8848
+        namespace: 035dcaf6-f934-4a62-b5cf-83e6fe333fc8
+    # 多网络时,首选网络
+    inetutils.preferred-networks:
+      - 10.11
+  datasource:
+    # 多数据源配置,优先使用
+    dynamic.enabled: false
+    # 单数据源配置
+    druid:
+      name: ds
+      driver-class-name: com.mysql.cj.jdbc.Driver
+      url: "jdbc:mysql://localhost:33060/maxkey"
+      username: root
+      password: maxkey
+logging:
+  level:
+    com.alibaba.cola.catchlog.CatchLogAspect: debug

+ 83 - 0
start/src/test/java/com/hosea/service/user/client/api/TenantApiTest.java

@@ -0,0 +1,83 @@
+package com.hosea.service.user.client.api;
+
+import cn.hutool.core.util.RandomUtil;
+import com.hosea.service.user.client.dto.data.TenantDTO;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.request.TenantAddCmd;
+import com.hosea.service.user.client.dto.request.TenantListPageQuery;
+import com.hosea.service.user.client.dto.request.TenantUpdateCmd;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@Order(1)
+@SpringBootTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class TenantApiTest {
+    public static final int TEST_NUM = 10;
+    @Resource
+    private TenantApi api;
+
+    @Order(1)
+    @RepeatedTest(value = TEST_NUM, name = "第{currentRepetition}次增加")
+    void add(RepetitionInfo info) {
+        TenantDTO dto = TenantDTO.builder()
+                .code("code" + info.getCurrentRepetition())
+                .name(RandomUtil.randomString(5))
+                .build();
+        TenantAddCmd cmd = TenantAddCmd.builder().tenant(dto).build();
+        Assertions.assertTrue(api.add(cmd).isSuccess());
+        Assertions.assertFalse(api.add(cmd).isSuccess());
+        Assertions.assertFalse(api.add(new TenantAddCmd()).isSuccess());
+        dto.setCode("");
+        Assertions.assertFalse(api.add(cmd).isSuccess());
+        Assertions.assertFalse(api.add(null).isSuccess());
+    }
+
+    @Test
+    @Order(2)
+    void get() {
+        Assertions.assertNotNull(api.get(TenantUniqueDTO.builder().code("code5").build()).getData().getName());
+        Assertions.assertFalse(api.add(null).isSuccess());
+        Assertions.assertFalse(api.get(new TenantUniqueDTO()).isSuccess());
+        Assertions.assertFalse(api.get(TenantUniqueDTO.builder().code("xxxx").build()).isSuccess());
+    }
+
+    @Order(3)
+    @RepeatedTest(value = TEST_NUM, name = "第{currentRepetition}次修改")
+    void update(RepetitionInfo info) {
+        int index = info.getCurrentRepetition();
+        TenantDTO dto = TenantDTO.builder()
+                .code("code" + index)
+                .name("name" + index)
+                .build();
+        Assertions.assertTrue(api.update(new TenantUpdateCmd(dto)).isSuccess());
+        Assertions.assertFalse(api.update(null).isSuccess());
+        Assertions.assertFalse(api.update(new TenantUpdateCmd()).isSuccess());
+        dto.setCode("");
+        Assertions.assertFalse(api.update(new TenantUpdateCmd(dto)).isSuccess());
+        dto.setCode("xxxx");
+        Assertions.assertFalse(api.update(new TenantUpdateCmd(dto)).isSuccess());
+    }
+
+    @Test
+    @Order(10)
+    void list() {
+        Integer size = 2;
+        TenantListPageQuery query = new TenantListPageQuery();
+        query.setPageIndex(3);
+        query.setPageSize(size);
+        query.setName("n");
+        query.setCode("c");
+        query.setOrderBy("GROUPCODE");
+        Assertions.assertEquals(api.list(query).getData().size(), size);
+        Assertions.assertEquals(api.list(null).getTotalCount(), TEST_NUM);
+        Assertions.assertEquals(api.list(new TenantListPageQuery()).getTotalCount(), TEST_NUM);
+        query.setName("");
+        query.setCode("");
+        Assertions.assertEquals(api.list(query).getTotalCount(), TEST_NUM);
+        query.setName("xxx");
+        query.setCode("xxx");
+        Assertions.assertEquals(api.list(query).getTotalCount(), 0);
+    }
+}

+ 78 - 0
start/src/test/java/com/hosea/service/user/client/api/TestEndClean.java

@@ -0,0 +1,78 @@
+package com.hosea.service.user.client.api;
+
+import cn.hutool.db.Db;
+import com.alibaba.druid.DbType;
+import com.alibaba.druid.sql.SQLUtils;
+import com.alibaba.druid.sql.ast.SQLStatement;
+import com.alibaba.druid.sql.ast.statement.SQLCreateTableStatement;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.util.FileCopyUtils;
+
+import javax.sql.DataSource;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 最后执行的测试
+ *
+ * @author hosea
+ * @date 2025-07-29
+ */
+@Slf4j
+@SpringBootTest
+@Order(Integer.MAX_VALUE)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class TestEndClean {
+    @jakarta.annotation.Resource
+    private DataSource ds;
+    @Value("${test.end.clean.db:false}")
+    private Boolean db;
+
+    @Test
+    @SneakyThrows
+    void clean() {
+        if (!db) {
+            return;
+        }
+        // 删除所有表
+        Db db = new Db(ds);
+        for (Map.Entry<String, String> entry : scanSqlFiles("classpath*:db/**/*.sql").entrySet()) {
+            String key = entry.getKey();
+            for (SQLStatement sql : SQLUtils.parseStatements(entry.getValue(), DbType.mysql)) {
+                if (sql instanceof SQLCreateTableStatement create) {
+                    String tableName = create.getTableName();
+                    log.info("删除表 {} -> {}", key, tableName);
+                    db.execute("drop table if exists " + tableName);
+                }
+            }
+        }
+        log.info("删除liquibase相关表");
+        db.execute("drop table if exists databasechangelog");
+        db.execute("drop table if exists databasechangeloglock");
+    }
+
+    @SneakyThrows
+    public static Map<String, String> scanSqlFiles(String path) {
+        Map<String, String> sqlMap = new HashMap<>();
+        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
+        Resource[] resources = resolver.getResources(path);
+        for (Resource resource : resources) {
+            String filename = resource.getFilename();
+            String content = FileCopyUtils.copyToString(
+                    new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8));
+            sqlMap.put(filename, content);
+        }
+        return sqlMap;
+    }
+}

+ 109 - 0
start/src/test/java/com/hosea/service/user/client/api/UserApiTest.java

@@ -0,0 +1,109 @@
+package com.hosea.service.user.client.api;
+
+import cn.hutool.core.util.RandomUtil;
+import com.hosea.cloud.web.login.JwtToken;
+import com.hosea.service.user.client.dto.data.TenantUniqueDTO;
+import com.hosea.service.user.client.dto.data.UserDTO;
+import com.hosea.service.user.client.dto.data.UserUniqueDTO;
+import com.hosea.service.user.client.dto.request.UserAddCmd;
+import com.hosea.service.user.client.dto.request.UserJoinTenantCmd;
+import com.hosea.service.user.client.dto.request.UserListByTenantPageQuery;
+import com.hosea.service.user.client.dto.request.UserUpdateCmd;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.List;
+import java.util.stream.IntStream;
+
+@Order(1)
+@SpringBootTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class UserApiTest {
+    @Resource
+    private UserApi api;
+
+    @Order(1)
+    @RepeatedTest(value = TenantApiTest.TEST_NUM, name = "第{currentRepetition}次增加")
+    void add(RepetitionInfo info) {
+        UserDTO dto = UserDTO.builder()
+                .name("user" + info.getCurrentRepetition())
+                .nickName(RandomUtil.randomString(5))
+                .build();
+        Assertions.assertTrue(api.add(new UserAddCmd(dto)).isSuccess());
+        Assertions.assertFalse(api.add(null).isSuccess());
+        Assertions.assertFalse(api.add(new UserAddCmd()).isSuccess());
+        dto.setName("");
+        Assertions.assertFalse(api.add(new UserAddCmd(dto)).isSuccess());
+    }
+
+    @Test
+    @Order(2)
+    void get() {
+        Assertions.assertTrue(api.get(JwtToken.builder().user("user5").build()).isSuccess());
+        Assertions.assertFalse(api.get(null).isSuccess());
+        Assertions.assertFalse(api.get(new JwtToken()).isSuccess());
+        Assertions.assertFalse(api.get(JwtToken.builder().user("xxxx").build()).isSuccess());
+    }
+
+    @Order(3)
+    @RepeatedTest(value = TenantApiTest.TEST_NUM, name = "第{currentRepetition}次修改")
+    void update(RepetitionInfo info) {
+        int index = info.getCurrentRepetition();
+        UserDTO dto = UserDTO.builder()
+                .name("user" + index)
+                .nickName("name" + index)
+                .build();
+        Assertions.assertTrue(api.update(new UserUpdateCmd(dto)).isSuccess());
+        Assertions.assertFalse(api.update(null).isSuccess());
+        Assertions.assertFalse(api.update(new UserUpdateCmd()).isSuccess());
+        dto.setName("");
+        Assertions.assertFalse(api.update(new UserUpdateCmd(dto)).isSuccess());
+        dto.setName("xxxx");
+        Assertions.assertFalse(api.update(new UserUpdateCmd(dto)).isSuccess());
+    }
+
+    @Order(4)
+    @Test
+    void join() {
+        UserUniqueDTO user = new UserUniqueDTO(null, "user1");
+        List<TenantUniqueDTO> tenant = List.of(new TenantUniqueDTO(null, "code1"));
+        Assertions.assertTrue(api.join(new UserJoinTenantCmd(user, tenant)).isSuccess());
+        Assertions.assertFalse(api.join(null).isSuccess());
+        Assertions.assertFalse(api.join(new UserJoinTenantCmd()).isSuccess());
+        Assertions.assertFalse(api.join(new UserJoinTenantCmd(user, null)).isSuccess());
+        Assertions.assertFalse(api.join(new UserJoinTenantCmd(null, tenant)).isSuccess());
+        Assertions.assertFalse(api.join(new UserJoinTenantCmd(new UserUniqueDTO(null, ""), tenant)).isSuccess());
+        Assertions.assertFalse(api.join(new UserJoinTenantCmd(new UserUniqueDTO(null, "xxxx"), tenant)).isSuccess());
+        Assertions.assertFalse(api.join(new UserJoinTenantCmd(user, List.of(new TenantUniqueDTO(null, "")))).isSuccess());
+        // 加入不存在的租户不会报错,但是加不进去
+        Assertions.assertTrue(api.join(new UserJoinTenantCmd(user, List.of(new TenantUniqueDTO(null, "xxxx")))).isSuccess());
+    }
+
+    @Order(5)
+    @RepeatedTest(value = TenantApiTest.TEST_NUM, name = "第{currentRepetition}次加入")
+    void joinAll(RepetitionInfo info) {
+        List<TenantUniqueDTO> tenants = IntStream.range(1, TenantApiTest.TEST_NUM)
+                .mapToObj(i -> new TenantUniqueDTO(null, "code" + i))
+                .toList();
+        UserUniqueDTO user = new UserUniqueDTO(null, "user" + info.getCurrentRepetition());
+        Assertions.assertTrue(api.join(new UserJoinTenantCmd(user, tenants)).isSuccess());
+    }
+
+    @Test
+    @Order(10)
+    void list() {
+        Integer size = 2;
+        UserListByTenantPageQuery query = new UserListByTenantPageQuery();
+        query.setPageIndex(3);
+        query.setPageSize(size);
+        query.setTenant(new TenantUniqueDTO(null, "code1"));
+        query.setOrderBy("GROUPCODE");
+        Assertions.assertEquals(api.list(query).getData().size(), size);
+        Assertions.assertFalse(api.list(null).isSuccess());
+        Assertions.assertFalse(api.list(new UserListByTenantPageQuery()).isSuccess());
+        Assertions.assertFalse(api.list(new UserListByTenantPageQuery(new TenantUniqueDTO(null, ""))).isSuccess());
+        Assertions.assertEquals(api.list(new UserListByTenantPageQuery(new TenantUniqueDTO(null, "code1"))).getTotalCount(), TenantApiTest.TEST_NUM);
+        Assertions.assertEquals(api.list(new UserListByTenantPageQuery(new TenantUniqueDTO(null, "xxx"))).getTotalCount(), 0);
+    }
+}

+ 33 - 0
start/src/test/resources/application.yml

@@ -0,0 +1,33 @@
+spring.config.import:
+  - classpath:web.yml
+  - classpath:mybatis-plus.yml
+---
+#---表示‌文档分隔符‌,在Spring Boot中后续文档块的配置会覆盖前面文档块的同名配置
+server.port: 10001
+spring.profiles.active: dev
+spring.application.name: user
+
+spring:
+  cloud:
+    nacos:
+      discovery.enabled: false
+      config.enabled: false
+  datasource:
+    # 多数据源配置,优先使用
+    dynamic.enabled: false
+    # 单数据源配置
+    druid:
+      name: test
+      url: "jdbc:mysql://localhost:3306/user_test"
+      username: root
+      liquibase:
+        change-log: "classpath:/db/db.changelog-test.xml"
+logging:
+  level:
+    com.alibaba.cola.catchlog.CatchLogAspect: debug
+    com.hosea.service.infra: debug
+
+# 单元测试结束之后的清除
+test.end.clean:
+  # 清除数据库里的表,默认false
+  db: true

+ 13 - 0
start/src/test/resources/db/db.changelog-test.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
+
+    <changeSet id="2" author="hosea" runOnChange="true">
+        <comment>初始化数据,runOnChange为true,表示文件发生变化时就会重新执行</comment>
+        <!--# relativeToChangelogFile=true,相对于当前文件,结果就是db/changelog/db/sql/init.sql-->
+        <sqlFile path="db/init.sql" relativeToChangelogFile="false"/>
+    </changeSet>
+
+</databaseChangeLog>

+ 152 - 0
start/src/test/resources/db/init.sql

@@ -0,0 +1,152 @@
+-- auto-generated definition
+create table mxk_userinfo
+(
+    ID                   varchar(45)                                    not null comment '编号'
+        primary key,
+    USERNAME             varchar(100)                                   not null comment '登录名',
+    PASSWORD             varchar(500)                                   not null comment '密码',
+    DECIPHERABLE         varchar(500)                                   not null comment 'DE密码',
+    AUTHNTYPE            tinyint unsigned default '1'                   null comment '认证类型',
+    MOBILE               varchar(45)                                    null comment '手机号码',
+    MOBILEVERIFIED       varchar(45)                                    null comment '手机号验证',
+    EMAIL                varchar(45)                                    null comment '邮箱',
+    EMAILVERIFIED        smallint unsigned                              null comment '邮箱验证',
+    DISPLAYNAME          varchar(45)                                    null comment '显示名称',
+    NICKNAME             varchar(45)                                    null comment '昵称',
+    PICTURE              longblob                                       null comment '头像',
+    TIMEZONE             varchar(45)      default 'Asia/Shanghai'       null comment '时区',
+    LOCALE               varchar(45)      default 'zh_CN'               null comment '地址',
+    PREFERREDLANGUAGE    varchar(45)      default 'zh_CN'               null comment '语言偏好',
+    PASSWORDQUESTION     varchar(45)                                    null comment '密码问题',
+    PASSWORDANSWER       varchar(45)                                    null comment '密码答案',
+    APPLOGINAUTHNTYPE    tinyint unsigned default '0'                   null comment '应用登录认证类型',
+    APPLOGINPASSWORD     varchar(45)                                    null comment '应用登录密码',
+    PROTECTEDAPPS        varchar(450)                                   null comment '应用登录密码保护应用',
+    THEME                varchar(45)      default 'default'             null comment '主题',
+    GRIDLIST             tinyint unsigned default '0'                   null comment '应用列表类型',
+    LOGINCOUNT           int unsigned     default '0'                   null comment '登录次数统计',
+    ONLINE               tinyint unsigned default '0'                   null comment '在线状态',
+    STATUS               tinyint unsigned default '1'                   null comment '用户状态',
+    ISLOCKED             tinyint unsigned default '1'                   null comment '锁定状态',
+    UNLOCKTIME           datetime         default '2020-01-01 01:01:01' null comment '解锁时间',
+    LASTLOGINIP          varchar(300)                                   null comment '最近登录IP地址',
+    LASTLOGINTIME        datetime         default '2020-01-01 01:01:01' null comment '最近登录时间',
+    LASTLOGOFFTIME       datetime         default '2020-01-01 01:01:01' null comment '最近注销时间',
+    BADPASSWORDTIME      datetime         default '2020-01-01 01:01:01' null comment '最近密码错误时间',
+    BADPASSWORDCOUNT     smallint unsigned                              null comment '密码错误次数',
+    PASSWORDLASTSETTIME  datetime         default '2020-01-01 01:01:01' null comment '最近密码修改时间',
+    PASSWORDSETTYPE      tinyint unsigned default '0'                   null comment '密码重置类型',
+    SHAREDSECRET         varchar(500)                                   null comment 'TIME-OPT密钥',
+    SHAREDCOUNTER        varchar(45)      default '0'                   null comment 'COUNTER-OPT密钥',
+    USERTYPE             varchar(45)      default 'Customer'            null comment '用户类型',
+    USERSTATE            varchar(45)      default 'RESIDENT'            null,
+    EMPLOYEENUMBER       varchar(45)                                    null comment '工号',
+    WINDOWSACCOUNT       varchar(45)                                    null comment 'AD域账号',
+    DIVISION             varchar(45)                                    null comment '分支',
+    COSTCENTER           varchar(45)                                    null comment '成本中心',
+    ORGANIZATION         varchar(45)                                    null comment '机构',
+    DEPARTMENTID         varchar(45)                                    null comment '部门编号',
+    DEPARTMENT           varchar(45)                                    null comment '部门',
+    JOBTITLE             varchar(45)                                    null comment '职务',
+    JOBLEVEL             varchar(45)                                    null comment '工作职级',
+    MANAGERID            varchar(45)                                    null comment '经理编号',
+    MANAGER              varchar(45)                                    null comment '经理名字',
+    ASSISTANTID          varchar(45)                                    null comment '助理编号',
+    ASSISTANT            varchar(45)                                    null comment '助理名字',
+    ENTRYDATE            varchar(45)                                    null comment '入司时间',
+    STARTWORKDATE        varchar(45)                                    null comment '开始工作时间',
+    QUITDATE             varchar(45)                                    null comment '离职日期',
+    SORTINDEX            tinyint unsigned default '1'                   null comment '部门内排序',
+    WORKEMAIL            varchar(45)                                    null comment '工作-邮件',
+    WORKPHONENUMBER      varchar(45)                                    null comment '工作-电话',
+    WORKCOUNTRY          varchar(45)      default 'CHN'                 null comment '工作-国家',
+    WORKREGION           varchar(45)                                    null comment '工作-省/市',
+    WORKLOCALITY         varchar(45)                                    null comment '工作-城市',
+    WORKSTREETADDRESS    varchar(45)                                    null comment '工作-街道',
+    WORKADDRESSFORMATTED varchar(45)                                    null comment '工作-地址全称',
+    WORKPOSTALCODE       varchar(45)                                    null comment '工作-邮编',
+    WORKFAX              varchar(45)                                    null comment '工作-传真',
+    WORKOFFICENAME       varchar(500)                                   null,
+    GIVENNAME            varchar(45)                                    null comment '名',
+    MIDDLENAME           varchar(45)                                    null comment '中间名',
+    FAMILYNAME           varchar(45)                                    null comment '姓',
+    HONORIFICPREFIX      varchar(45)                                    null comment '前缀',
+    HONORIFICSUFFIX      varchar(45)                                    null comment '后缀',
+    FORMATTEDNAME        varchar(400)                                   null comment '用户全名',
+    IDTYPE               tinyint unsigned default '0'                   null comment '证件类型',
+    IDCARDNO             varchar(45)                                    null comment '证件号码',
+    EDUCATION            varchar(200)                                   null comment '学历',
+    GRADUATEFROM         varchar(500)                                   null comment '毕业院校',
+    GRADUATEDATE         varchar(45)                                    null comment '毕业日期',
+    MARRIED              tinyint unsigned default '0'                   null comment '婚姻状态',
+    BIRTHDATE            varchar(45)                                    null comment '生日',
+    NAMEZHSPELL          varchar(100)                                   null comment '名字中文拼音',
+    NAMEZHSHORTSPELL     varchar(45)                                    null comment '名字中文拼音简称',
+    GENDER               tinyint unsigned                               null comment '性别',
+    WEBSITE              varchar(50)                                    null comment '个人主页',
+    WEIXINFOLLOW         tinyint unsigned                               null comment '微信关注',
+    DEFINEIM             varchar(45)                                    null comment 'IM账号',
+    HOMEEMAIL            varchar(45)                                    null comment '家庭-邮件',
+    HOMEPHONENUMBER      varchar(45)                                    null comment '家庭-电话',
+    HOMECOUNTRY          varchar(45)      default 'CHN'                 null comment '家庭-省/市',
+    HOMEREGION           varchar(45)                                    null comment '家庭-市',
+    HOMELOCALITY         varchar(45)                                    null comment '家庭-区',
+    HOMESTREETADDRESS    varchar(45)                                    null comment '家庭-街道',
+    HOMEADDRESSFORMATTED varchar(45)                                    null comment '家庭-地址全称',
+    HOMEPOSTALCODE       varchar(45)                                    null comment '家庭-邮编',
+    HOMEFAX              varchar(45)                                    null comment '家庭-传真',
+    EXTRAATTRIBUTE       varchar(4000)                                  null comment '用户扩展属性',
+    CREATEDBY            varchar(45)                                    null comment '创建人',
+    CREATEDDATE          datetime         default CURRENT_TIMESTAMP     null comment '创建时间',
+    MODIFIEDBY           varchar(45)                                    null comment '修改人',
+    MODIFIEDDATE         datetime                                       null comment '修改时间',
+    DESCRIPTION          varchar(400)                                   null comment '描述',
+    LDAPDN               varchar(1000)                                  null,
+    INSTID               varchar(45)                                    not null,
+    Regionhistory        text                                           null,
+    passwordhistory      text                                           null,
+    constraint USERNAME_UNIQUE
+        unique (USERNAME)
+)
+    comment 'USER INFO DEFINE' charset = utf8mb3;
+
+create index EMPLOYEENUMBER_UNIQUE
+    on mxk_userinfo (EMPLOYEENUMBER);
+
+-- auto-generated definition
+create table mxk_groups
+(
+    ID           varchar(45)                        not null comment 'ID',
+    GROUPCODE    varchar(45)                        null,
+    GROUPNAME    varchar(100)                       null comment 'GROUP NAME',
+    category     varchar(20)                        null comment '动态用户组,dynamic动态组 static静态组app应用账号组',
+    FILTERS      text                               null comment '过滤条件SQL',
+    ORGIDSLIST   text                               null comment '机构列表',
+    RESUMETIME   varchar(45)                        null comment 'RESUMETIME',
+    SUSPENDTIME  varchar(45)                        null comment 'SUSPENDTIME',
+    STATUS       tinyint unsigned                   null comment 'STATUS',
+    CREATEDBY    varchar(45)                        null comment 'CREATEDBY',
+    ISDEFAULT    tinyint unsigned                   null comment 'ISDEFAULT',
+    CREATEDDATE  datetime default CURRENT_TIMESTAMP null comment 'CREATEDDATE',
+    MODIFIEDBY   varchar(45)                        null comment 'MODIFIEDBY',
+    MODIFIEDDATE datetime                           null comment 'MODIFIEDDATE',
+    DESCRIPTION  varchar(500)                       null comment 'DESCRIPTION',
+    INSTID       varchar(45)                        not null
+)
+    charset = utf8mb3;
+
+-- auto-generated definition
+create table mxk_group_member
+(
+    ID          varchar(100) default ''                not null comment 'ID'
+        primary key,
+    GROUPID     varchar(100)                           not null comment 'GROUPID',
+    MEMBERID    varchar(100)                           not null comment 'MEMBERID USERID OR GROUP ID',
+    TYPE        varchar(45)                            not null comment 'TYPE  USER OR GROUP',
+    CREATEDDATE datetime     default CURRENT_TIMESTAMP null,
+    INSTID      varchar(45)                            not null,
+    constraint GROUPID_MEMBERID
+        unique (GROUPID, MEMBERID, TYPE)
+)
+    charset = utf8mb3;
+