{"id":50991017,"url":"https://github.com/wangpengxpy/gj.spring.pf4j","last_synced_at":"2026-06-20T03:04:16.808Z","repository":{"id":364613330,"uuid":"1259641725","full_name":"wangpengxpy/gj.spring.pf4j","owner":"wangpengxpy","description":"A lightweight, modular plugin framework powered by PF4J and Spring, with no heavyweight Spring Boot dependency","archived":false,"fork":false,"pushed_at":"2026-06-13T18:04:56.000Z","size":290,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-13T19:23:56.496Z","etag":null,"topics":["framework","java","modular","pf4j-spring","spring","spring-boot"],"latest_commit_sha":null,"homepage":"https://github.com/wangpengxpy/gj.spring.pf4j","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/wangpengxpy.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-04T17:59:03.000Z","updated_at":"2026-06-13T17:54:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/wangpengxpy/gj.spring.pf4j","commit_stats":null,"previous_names":["wangpengxpy/gj.spring.pf4j"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/wangpengxpy/gj.spring.pf4j","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/wangpengxpy%2Fgj.spring.pf4j","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/wangpengxpy%2Fgj.spring.pf4j/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/wangpengxpy%2Fgj.spring.pf4j/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/wangpengxpy%2Fgj.spring.pf4j/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/wangpengxpy","download_url":"https://codeload.github.com/wangpengxpy/gj.spring.pf4j/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/wangpengxpy%2Fgj.spring.pf4j/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34555494,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-20T02:00:06.407Z","response_time":98,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["framework","java","modular","pf4j-spring","spring","spring-boot"],"created_at":"2026-06-20T03:04:16.121Z","updated_at":"2026-06-20T03:04:16.798Z","avatar_url":"https://github.com/wangpengxpy.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# gj.spring.pf4j\n\n[![Java](https://img.shields.io/badge/Java-17%2B-orange)](https://adoptium.net/)\n[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/wangpengxpy/gj.spring.pf4j/blob/main/LICENSE)\n[![Maven Central](https://img.shields.io/maven-central/v/io.github.wangpengxpy/gj-pf4j?color=green)](https://central.sonatype.com/artifact/io.github.wangpengxpy/gj-pf4j)\n[![Stars](https://img.shields.io/github/stars/wangpengxpy/gj.spring.pf4j?style=social)](https://github.com/wangpengxpy/gj.spring.pf4j/stargazers)\n\nA lightweight, modular plugin framework powered by PF4J and Spring, with no heavyweight Spring Boot dependency. Supports both Spring MVC and Spring WebFlux routing — auto-adapts to the host application's web stack.\n\n\u003e [中文文档](README_CN.md)\n\n---\n\n## Table of Contents\n\n1. [Overview](#1-overview)\n2. [Quick Start](#2-quick-start)\n3. [Plugin Project Structure](#3-plugin-project-structure)\n4. [Plugin Lifecycle](#4-plugin-lifecycle)\n5. [REST Endpoints](#5-rest-endpoints)\n6. [Data Access](#6-data-access)\n7. [Database Auto-Migration](#7-database-auto-migration)\n8. [Object Mapping](#8-object-mapping)\n9. [Plugin Configuration Management](#9-plugin-configuration-management)\n10. [Real-Time Communication](#10-real-time-communication)\n11. [Internationalization (i18n)](#11-internationalization-i18n)\n12. [Import/Export](#12-importexport)\n13. [Scheduled Tasks](#13-scheduled-tasks)\n14. [In-Process Event Bus](#14-in-process-event-bus)\n15. [OpenAPI Documentation](#15-openapi-documentation)\n16. [Plugin Packaging \u0026 Deployment](#16-plugin-packaging--deployment)\n17. [Runtime Plugin Management API](#17-runtime-plugin-management-api)\n18. [Appendix: Host Application Integration](#18-appendix-host-application-integration)\n    * [Version Compatibility](#181-version-compatibility)\n    * [Host Application Entry Point](#182-host-application-entry-point)\n    * [Optional: @GJModelMapperScan (Shared Models)](#183-optional-gjmodelmapperscan-shared-models)\n19. [Claude Code Integration](#19-claude-code-integration)\n20. [FAQ](#20-faq)\n\n---\n\n## 1. Overview\n\ngj.spring.pf4j is a lightweight, modular plugin framework built on [PF4J](https://pf4j.org/) with Spring integration. It depends only on Spring core libraries (spring-context, spring-webmvc, spring-jdbc, etc.) and does not require Spring Boot as a runtime dependency. It is suitable for projects that need a modular architecture without the full Spring Boot stack.\n\n### Core Capabilities\n\n- **[Plugin Lifecycle Management](#4-plugin-lifecycle)** — load, start, stop, restart, unload, and delete plugins at runtime\n- **[Runtime Plugin Management API](#17-runtime-plugin-management-api)** — `GJPluginService` provides lock-controlled runtime management\n- **[REST Endpoints](#5-rest-endpoints)** — `@RestController` beans are auto-detected and registered into the main application's route table, supporting both MVC and WebFlux\n- **[Dual Routing Mode](#52-spring-mvc-vs-webflux-dual-routing)** — supports both Spring MVC (Servlet) and Spring WebFlux (Reactive) routing; plugins require zero changes\n- **[OpenAPI Documentation](#15-openapi-documentation)** — powered by SpringDoc; each plugin auto-generates an independent `GroupedOpenApi`\n- **[Data Access](#6-data-access)** — powered by [MyBatis-Plus](https://baomidou.com/); each plugin gets its own `SqlSessionFactory`, `SqlSessionTemplate`, and `TransactionManager`, all sharing the main application's `DataSource`\n- **[SQL Keyword Quoting](#66-sql-keyword-quoting)** — MyBatis-Plus `InnerInterceptor` automatically wraps column names with database-specific quote characters to prevent reserved-keyword conflicts; both host app and plugins can register keyword definitions via `GJTableKeywordProvider`\n- **[Database Auto-Migration](#7-database-auto-migration)** — automatic `@TableName` entity schema migration (CREATE TABLE / ADD COLUMN only), supports 7 databases, production-safe\n- **[Object Mapping](#8-object-mapping)** — powered by [ModelMapper](https://modelmapper.org/); plugins implement `GJPluginModelMapperConfig`, auto-discovered via Spring bean scanning\n- **[Import/Export](#12-importexport)** — powered by [EasyExcel](https://easyexcel.opensource.alibaba.com/); multi-sheet read/write with automatic i18n header translation\n- **[Real-Time Communication](#10-real-time-communication)** — powered by [netty-socketio](https://github.com/mrniko/netty-socketio); built-in Hub pattern (SignalR-style) with group and user-targeted messaging\n- **[Scheduled Tasks](#13-scheduled-tasks)** — powered by [Quartz](https://www.quartz-scheduler.org/); supports cron, fixed-interval, and run-once execution\n- **[In-Process Event Bus](#14-in-process-event-bus)** — lightweight in-process event bus with sync/async publishing and Ant-style wildcard matching\n- **[Internationalization (i18n)](#11-internationalization-i18n)** — per-plugin `messages.properties` with fallback to the main application\n\n---\n\n## 2. Quick Start\n\n### 2.1 Install the Archetype Locally\n\n```bash\ncd src/gj-archetypes\nmvn clean install\n```\n\n### 2.2 Generate a Plugin Project\n\n```bash\nmvn archetype:generate \\\n  -DarchetypeGroupId=io.github.wangpengxpy \\\n  -DarchetypeArtifactId=gj-archetype \\\n  -DarchetypeVersion=1.0.4 \\\n  -DgroupId=com.example \\\n  -DpluginName=user \\\n  -DpackagePrefix=gj.module\n```\n\n**Parameters:**\n\n| Parameter | Meaning | Example |\n|---|---|---|\n| `groupId` | Maven groupId for the generated project | `com.example` |\n| `pluginName` | Short plugin name (used for class/package naming) | `user` |\n| `packagePrefix` | Java package prefix for the plugin | `gj.module` |\n\nThe generated `plugin.id` will be `gj.module.user`, and all Java classes will reside under the `gj.module.user` package.\n\n### 2.3 Generated Project Structure\n\n```\nuser-plugin/\n├── pom.xml                          # Plugin POM, depends on gj-pf4j\n├── pom-parent.xml                   # Build parent POM (packaging rules)\n└── src/\n    └── main/\n        ├── java/\n        │   └── gj/module/user/\n        │       ├── UserPlugin.java                      # Plugin entry point\n        │       ├── UserConfig.java                      # Plugin configuration\n        │       ├── controllers/\n        │       │   └── UserController.java              # REST controller\n        │       ├── dao/\n        │       │   └── UserMapper.java                  # MyBatis Mapper\n        │       ├── dto/\n        │       │   └── EgroupDTO.java                   # Data transfer object\n        │       ├── model/\n        │       │   └── Test.java                        # Database entity\n        │       ├── modelmapper/\n        │       │   └── UserModelMapperConfig.java       # ModelMapper config\n        │       ├── request/\n        │       │   └── UserEventRequest.java            # Request DTO\n        │       ├── response/\n        │       │   ├── UserResponse.java                # List response DTO\n        │       │   └── UserEventResponse.java           # Event response DTO\n        │       └── service/\n        │           ├── UserService.java                 # Service interface\n        │           └── impl/\n        │               └── UserServiceImpl.java         # Service implementation\n        └── resources/\n            ├── plugin.properties                # PF4J plugin descriptor\n            └── gj.module.user.properties        # Plugin business configuration\n```\n\n---\n\n## 3. Plugin Project Structure\n\n### 3.1 Directory Conventions\n\n| Directory / File | Purpose | Notes |\n|---|---|---|\n| `{Plugin}.java` | Plugin entry class | Extends `GJPlugin`, lifecycle hooks |\n| `{Plugin}Config.java` | Plugin config class | `@ConfigurationProperties` binding |\n| `controllers/` | REST controllers | `@RestController`, auto-registered as routes |\n| `dao/` | Data access layer | MyBatis Mapper interfaces, extending `BaseMapper\u003cT\u003e` |\n| `model/` | Database entities | MyBatis-Plus `@TableName` entities |\n| `dto/` | Data transfer objects | Non-persistent DTOs |\n| `request/` | Request objects | API input DTOs |\n| `response/` | Response objects | API return DTOs |\n| `service/` | Service interfaces | Business logic interfaces |\n| `serviceimpl/` | Service implementations | `@Service`, `@Transactional` |\n| `modelmapper/` | Mapping configuration | Implements `GJPluginModelMapperConfig` |\n| `plugin.properties` | PF4J descriptor | `plugin.id`, `plugin.class`, `plugin.version` |\n| `{pluginId}.properties` | Plugin business config | Business parameters bound to Config class |\n\n### 3.2 plugin.properties\n\n```properties\nplugin.id=gj.module.user\nplugin.class=gj.module.user.UserPlugin\nplugin.version=1.0.0-SNAPSHOT\nplugin.description=\nplugin.provider=\nplugin.dependencies=\n```\n\n\u003e **Constraint:** `plugin.id` must exactly match the plugin's main package name.\n\n### 3.3 {pluginId}.properties (Plugin Business Config)\n\n```properties\ngj.module.user.enabled=true\ngj.module.user.value=iot\n```\n\n### 3.4 pom-parent.xml Build Rules\n\nThe `pom-parent.xml` is a standalone parent POM with a fully automated build pipeline. Running `mvn clean package` executes the following steps in order:\n\n#### Step 1: Dependency Classification — Auto-Separate Shared vs. Private JARs\n\n`maven-dependency-plugin` scans all runtime dependencies at the `prepare-package` phase and automatically classifies them based on the `excludeGroupIds` list:\n\n| Dependency Type | Criteria | Handling |\n|---|---|---|\n| **Shared (provided by host)** | groupId in the exclusion list | **Skipped** — not copied, not packaged |\n| **Plugin-private** | groupId NOT in the exclusion list | Copied to `target/lib/`, packaged into `lib/` |\n\nThe exclusion list covers all frameworks already integrated by the host application: the entire Spring ecosystem, MyBatis-Plus, PF4J, Jackson, Netty, SocketIO, ModelMapper, EasyExcel, Lombok, SLF4J, Hibernate Validator, Jakarta, and more — 60+ groupIds in total.\n\n```xml\n\u003c!-- Exclusion config in pom-parent.xml (partial excerpt) --\u003e\n\u003cexcludeGroupIds\u003e\n    org.springframework, org.springframework.boot,  \u003c!-- Spring Framework --\u003e\n    org.mybatis, com.baomidou,                      \u003c!-- MyBatis-Plus --\u003e\n    org.pf4j,                                        \u003c!-- PF4J Plugin Framework --\u003e\n    com.fasterxml.jackson.core,                      \u003c!-- Jackson --\u003e\n    io.netty, com.corundumstudio.socketio,           \u003c!-- Netty + Socket.IO --\u003e\n    org.modelmapper,                                  \u003c!-- ModelMapper --\u003e\n    org.projectlombok,                                \u003c!-- Lombok --\u003e\n    org.slf4j, ch.qos.logback,                       \u003c!-- Logging --\u003e\n    jakarta.servlet, jakarta.annotation,              \u003c!-- Jakarta --\u003e\n    com.alibaba,                                      \u003c!-- EasyExcel --\u003e\n    ...\n\u003c/excludeGroupIds\u003e\n```\n\n\u003e **This means:** you simply declare dependencies in `pom.xml`. The build automatically decides — shared JARs are excluded; only plugin-specific third-party libraries end up in `lib/`.\n\n#### Step 2: Private Library Aggregation\n\nIf the plugin has **non-Maven-Central private JARs** (e.g., internal SDKs, customized libraries), place them in `src/lib/`. They are automatically merged into `target/lib/` during the build.\n\n```\nuser-plugin/\n  src/\n    lib/\n      internal-sdk-1.0.jar        ← manually placed, auto-packaged into lib/\n```\n\n#### Step 3: MANIFEST.MF Auto-Generation\n\n`maven-antrun-plugin` scans all JARs under `target/lib/` and generates the MANIFEST.MF:\n\n```manifest\nManifest-Version: 1.0\nClass-Path: lib/internal-sdk-1.0.jar lib/some-third-party.jar\nPlugin-Id: gj.module.user\nPlugin-Version: 1.0.0-SNAPSHOT\n```\n\n`maven-jar-plugin` uses this MANIFEST.MF when packaging the JAR, ensuring that `GJJarPluginLoader` correctly resolves and loads `lib/` dependencies at runtime.\n\n#### Step 4: Assemble Output Directory\n\nAll artifacts converge into `target/plugins/{artifactId}/`:\n\n```\ntarget/plugins/gj.module.user/\n├── gj.module.user-1.0.0-SNAPSHOT.jar     ← Plugin main JAR (with generated MANIFEST.MF)\n├── gj.module.user.json                   ← Copied from src/main/resources\n└── lib/                                   ← Plugin-private dependencies (auto-classified + src/lib/ merged)\n    ├── internal-sdk-1.0.jar\n    └── some-third-party.jar\n```\n\nThe entire directory can be copied directly into the host application's `plugins/` directory for deployment (see Chapter 16).\n\n---\n\n## 4. Plugin Lifecycle\n\nEvery plugin must create a class extending `GJPlugin`. The framework controls initialization through two hooks:\n\n### 4.1 Plugin Entry Class\n\n```java\npackage gj.module.user;\n\nimport gj.pf4j.GJPlugin;\nimport org.springframework.context.annotation.AnnotationConfigApplicationContext;\n\npublic class UserPlugin extends GJPlugin {\n\n    /**\n     * Called BEFORE the Spring context is refreshed.\n     * Use this to programmatically register beans (do NOT rely on\n     * @Component/@Service stereotype scanning here).\n     */\n    @Override\n    protected AnnotationConfigApplicationContext beforeApplicationContextRefresh(\n            AnnotationConfigApplicationContext context) {\n        // Example: register a manually created bean\n        context.registerBean(\"customBean\", CustomBean.class);\n        return context;\n    }\n\n    /**\n     * Called AFTER the Spring context has been fully refreshed.\n     * Beans can be safely retrieved and initialized here.\n     */\n    @Override\n    protected void afterApplicationContextReady(\n            AnnotationConfigApplicationContext context) {\n        // Example: retrieve a bean and run initialization logic\n        UserService userService = context.getBean(UserService.class);\n        userService.initialize();\n    }\n}\n```\n\n### 4.2 Lifecycle Flow\n\n```\nPlugin loaded (loadPlugin)\n  └─ GJSpringPlugin wrapper created\n       └─ start()\n            ├─ preCreateApplicationContext()      ← Creates AnnotationConfigApplicationContext\n            │    ├─ Registers GJPluginLifecycleManager\n            │    ├─ Sets GJPluginBeanNameGenerator (prevents bean name collisions)\n            │    ├─ Sets parent context = main app ApplicationContext\n            │    └─ Scans the plugin package\n            │\n            ├─ beforeApplicationContextRefresh()  ← [Hook 1] Programmatic bean registration\n            │\n            ├─ registerPluginResources()          ← Registers i18n / MyBatis / config files\n            │\n            ├─ context.refresh()                  ← Spring container refresh\n            │\n            └─ afterApplicationContextReady()     ← [Hook 2] Post-init logic\n```\n\n### 4.3 Key Constraints\n\n- **Naming consistency:** `plugin.id` (e.g., `gj.module.user`) must exactly match the package name of the plugin's main class. Mismatch throws `IllegalStateException` at startup.\n- **Bean registration pattern:** The framework auto-scans the plugin package via `scan()`, detecting `@Component`, `@Service`, `@Configuration`, and other Spring stereotypes. `beforeApplicationContextRefresh()` is called before the context is refreshed — use `context.registerBean()` for programmatic registration.\n- **Bean name isolation:** The framework automatically prefixes all plugin bean names with `{pluginId}.` to prevent collisions (e.g., `gj.module.user.userService`).\n\n---\n\n## 5. REST Endpoints\n\n### 5.1 Basic Usage\n\nCreate `@RestController` classes in the plugin. The framework auto-detects all `@RequestMapping`-annotated methods and registers them into the main application's route table at plugin startup.\n\n```java\npackage gj.module.user.controllers;\n\nimport gj.module.user.response.UserResponse;\nimport gj.module.user.service.UserService;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n@RestController\n@RequestMapping(\"/api/v1/user\")\npublic class UserController {\n\n    private final UserService userService;\n\n    public UserController(UserService userService) {\n        this.userService = userService;\n    }\n\n    @GetMapping(\"/list\")\n    public List\u003cUserResponse\u003e getList() {\n        return userService.getList();\n    }\n\n    @GetMapping(\"/{id}\")\n    public UserResponse getById(@PathVariable Integer id) {\n        return userService.getById(id);\n    }\n\n    @PostMapping(\"/create\")\n    public boolean create(@RequestBody UserCreateRequest request) {\n        return userService.create(request);\n    }\n\n    @DeleteMapping(\"/{id}\")\n    public boolean delete(@PathVariable Integer id) {\n        return userService.delete(id);\n    }\n}\n```\n\n### 5.2 Spring MVC vs. WebFlux Dual Routing\n\nThe framework **supports both Spring MVC (Servlet stack) and Spring WebFlux (Reactive stack)**, automatically adapting to the host application's web architecture. Plugin-side code is **identical** for both modes — you always use `@RestController` + `@RequestMapping`. The only difference is which HandlerMapping is used at runtime:\n\n| Host App Architecture | Registered HandlerMapping | Routing Style |\n|---|---|---|\n| Spring MVC (Servlet) | `GJPluginRequestMappingHandlerMapping` | `@RequestMapping` annotation-based |\n| Spring WebFlux (Reactive) | `GJPluginWebFluxRequestMappingHandlerMapping` | `@RequestMapping` annotation-based |\n\n#### Annotation-Based Routing\n\nWrite `@RestController` as usual in the plugin. The framework automatically selects the correct HandlerMapping based on the host application's web type. **Plugins do not need to know whether the host uses MVC or WebFlux.**\n\n#### WebFlux Functional Routing (WebFlux Mode Only)\n\nWhen the host application uses WebFlux, the framework also supports defining routes via `RouterFunction` (functional style). Inject `GJPluginWebFluxRouterFunctionRegistry` and call `register()`:\n\n```java\npackage gj.module.user.route;\n\nimport gj.pf4j.webflux.GJPluginWebFluxRouterFunctionRegistry;\nimport jakarta.annotation.PostConstruct;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.reactive.function.server.*;\n\nimport java.util.List;\n\nimport static org.springframework.web.reactive.function.server.RequestPredicates.GET;\n\n@Configuration\npublic class UserRouterConfig {\n\n    private final GJPluginWebFluxRouterFunctionRegistry registry;\n    private final UserHandler handler;\n\n    public UserRouterConfig(GJPluginWebFluxRouterFunctionRegistry registry, UserHandler handler) {\n        this.registry = registry;\n        this.handler = handler;\n    }\n\n    @PostConstruct\n    public void registerRoutes() {\n        RouterFunction\u003cServerResponse\u003e routes = RouterFunctions\n            .route(GET(\"/api/v1/user/list\"), handler::getList)\n            .andRoute(GET(\"/api/v1/user/{id}\"),\n                request -\u003e handler.getById(request.pathVariable(\"id\")));\n        registry.register(List.of(routes));\n    }\n}\n```\n\n\u003e Call `unregister()` for cleanup on hot-unload. See **[Appendix: Host Application Integration](#18-appendix-host-application-integration)** for detailed MVC / WebFlux configuration steps.\n\n---\n\n## 6. Data Access\n\nPowered by [MyBatis-Plus](https://baomidou.com/).\n\n### 6.1 DAO Package Convention\n\nMapper interfaces must reside in the `{pluginId}.dao` package (dots replace hyphens). For example, `plugin.id = gj.module.user` → DAO package is `gj.module.user.dao`.\n\n### 6.2 Entity Class\n\n```java\npackage gj.module.user.model;\n\nimport com.baomidou.mybatisplus.annotation.*;\nimport lombok.Data;\n\n@Data\n@TableName(\"user\")\npublic class User {\n\n    @TableId(value = \"id\", type = IdType.AUTO)\n    private Integer id;\n\n    @TableField(\"name\")\n    private String name;\n\n    @TableField(\"email\")\n    private String email;\n\n    @TableField(\"description\")\n    private String description;\n}\n```\n\n### 6.3 Mapper Interface\n\n```java\npackage gj.module.user.dao;\n\nimport com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport gj.module.user.model.User;\n\npublic interface UserMapper extends BaseMapper\u003cUser\u003e {\n}\n```\n\n### 6.4 Service Implementation\n\n```java\npackage gj.module.user.serviceimpl;\n\nimport com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;\nimport com.baomidou.mybatisplus.core.toolkit.Wrappers;\nimport gj.module.user.dao.UserMapper;\nimport gj.module.user.model.User;\nimport gj.module.user.response.UserResponse;\nimport gj.module.user.service.UserService;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport java.util.List;\n\n@Slf4j\n@Service\n@Transactional\npublic class UserServiceImpl implements UserService {\n\n    private final UserMapper userMapper;\n\n    public UserServiceImpl(UserMapper userMapper) {\n        this.userMapper = userMapper;\n    }\n\n    @Override\n    public List\u003cUserResponse\u003e getList() {\n        LambdaQueryWrapper\u003cUser\u003e queryWrapper = Wrappers.lambdaQuery();\n        List\u003cUser\u003e users = userMapper.selectList(queryWrapper);\n        return users.stream().map(u -\u003e {\n            UserResponse resp = new UserResponse();\n            resp.setId(u.getId());\n            resp.setName(u.getName());\n            resp.setEmail(u.getEmail());\n            return resp;\n        }).toList();\n    }\n}\n```\n\n### 6.5 Data Layer Isolation\n\n`GJPluginMybatisSqlSessionManager` creates for each plugin:\n\n- A dedicated `SqlSessionFactory` (camelCase mapping, no cache, no lazy loading)\n- A dedicated `SqlSessionTemplate` (cached for reuse)\n- A dedicated `DataSourceTransactionManager`\n- A `MapperScannerConfigurer` scoped to the plugin's DAO package only\n\nAll plugins share the main application's `DataSource`. Resources are cleaned up automatically when a plugin stops.\n\n### 6.6 SQL Keyword Quoting\n\nThe framework includes a MyBatis-Plus `InnerInterceptor` that automatically detects the database type at runtime and wraps column names with the correct quote character when they conflict with reserved keywords (e.g., `order`, `comment`, `context`).\n\n**Quote character by database:**\n\n| Database | Quote |\n|----------|-------|\n| MySQL | `` ` `` (backtick) |\n| DM / PostgreSQL / GaussDB / KingbaseES / SQLite / Oracle | `\"` (double quote) |\n\n**Extension point:** `GJTableKeywordProvider` — both the host application and plugins implement this interface and register as Spring Beans to declare table-column mappings that may conflict with database keywords.\n\n**Host application example:**\n\n```java\npackage com.example.config;\n\nimport gj.pf4j.mybatis.interceptor.GJTableKeywordProvider;\nimport org.springframework.stereotype.Component;\nimport java.util.Map;\nimport java.util.Set;\n\n@Component\npublic class AppKeywords implements GJTableKeywordProvider {\n    @Override\n    public Map\u003cString, Set\u003cString\u003e\u003e getTableKeywords() {\n        return Map.of(\n            \"el-t1\",   Set.of(\"order\"),\n            \"el-t2\", Set.of(\"comment\")\n        );\n    }\n}\n```\n\n**Plugin example:**\n\n```java\npackage gj.module.user.keyword;\n\nimport gj.pf4j.mybatis.interceptor.GJTableKeywordProvider;\nimport org.springframework.stereotype.Component;\nimport java.util.Map;\nimport java.util.Set;\n\n@Component\npublic class UserKeywords implements GJTableKeywordProvider {\n    @Override\n    public Map\u003cString, Set\u003cString\u003e\u003e getTableKeywords() {\n        return Map.of(\n            \"user_table\", Set.of(\"level\", \"comment\", \"type\")\n        );\n    }\n}\n```\n\nHost app providers are auto-scanned at startup; plugin providers are auto-scanned after the plugin context is refreshed. Table and column names are case-insensitive.\n\n---\n\n## 7. Database Auto-Migration\n\nThe framework provides automatic database schema migration for plugin entities. When a plugin starts, `@TableName` entities are automatically scanned and compared against the current database schema. Missing tables and columns are created automatically — no manual SQL migration scripts required.\n\n### 7.1 Supported Databases\n\nThe migration engine supports **7 databases** with automatic dialect detection via JDBC connection metadata:\n\n| Database | Detection |\n|----------|-----------|\n| MySQL | JDBC URL or product name |\n| PostgreSQL | JDBC URL or product name |\n| GaussDB / openGauss | JDBC URL or product name |\n| KingbaseES | JDBC URL or product name |\n| DM (Dameng) | JDBC URL or product name |\n| SQLite | JDBC URL or product name |\n| Oracle | JDBC URL or product name |\n\nNo additional configuration is needed — the dialect (identifier quoting, type mapping, DDL rendering) is resolved automatically from the `DataSource` connection.\n\n### 7.2 Production Safety\n\nThe migration engine follows a **strict additive-only policy**. Only two DDL operations are ever generated:\n\n| Operation | Condition |\n|-----------|-----------|\n| **CREATE TABLE** | Table does not exist in the database |\n| **ALTER TABLE ADD COLUMN** | Column does not exist in the target table |\n\nNo `DROP TABLE`, `DROP COLUMN`, `ALTER COLUMN`, `RENAME`, or any other destructive DDL is ever produced. Existing tables, columns, and data are never modified. This makes auto-migration safe for production use.\n\n### 7.3 Plugin Auto-Migration\n\nPlugins require **zero configuration** for migration. Any `@TableName` entity class placed under the plugin's package is automatically scanned during plugin startup. The framework compares the entity model against the live database schema and executes any necessary `CREATE TABLE` or `ADD COLUMN` statements.\n\nMigration is triggered automatically when:\n\n- A **new plugin with new entities** is deployed — tables are created\n- An **existing plugin adds a new field** to an entity — the column is added\n- An **existing plugin adds a new entity** — the table is created\n\nMigration is transparently disabled (zero overhead) when no `GJPluginModelMigrator` bean exists in the main application context — i.e., when `@EnableGJMigration` is not used.\n\n### 7.4 Share Model Migration\n\nThe host application can also migrate its own shared model entities (e.g., `User`, `Menu`, `Role` — common entities shared across all plugins). Use the `@EnableGJMigration` annotation with `basePackages` to specify the shared model package paths:\n\n```java\n@SpringBootApplication\n@ComponentScan(\"gj\")\n@EnableGJMigration(basePackages = {\"com.example.common.model\", \"com.example.platform.entity\"})\npublic class GJApplication {\n    public static void main(String[] args) {\n        SpringApplication.run(GJApplication.class, args);\n    }\n}\n```\n\n**Execution guarantees:**\n\n- **Priority**: Share models are migrated **before any plugin** — the framework guarantees shared tables exist before plugins reference them\n- **Once per JVM lifecycle**: Share model migration runs exactly once, regardless of how many plugins are loaded or restarted\n\n\u003e **Note:** Without `@EnableGJMigration`, the host application does not create a `GJPluginModelMigrator` bean, and the entire migration subsystem is inactive. Add the annotation only when you need auto-migration.\n\n---\n\n## 8. Object Mapping\n\nPowered by the open-source library [ModelMapper](https://modelmapper.org/).\n\n### 8.1 Mapping Configuration Class\n\nImplement `GJPluginModelMapperConfig` in your plugin and annotate it with `@Component` to register type mapping rules:\n\n```java\npackage gj.module.user.modelmapper;\n\nimport gj.module.user.dto.UserDTO;\nimport gj.module.user.model.User;\nimport gj.pf4j.modelmapper.GJPluginModelMapperConfig;\nimport gj.pf4j.modelmapper.GJPluginTypeMapConfig;\nimport org.springframework.stereotype.Component;\n\nimport java.util.List;\n\n@Component\npublic class UserModelMapperConfig implements GJPluginModelMapperConfig {\n\n    @Override\n    public List\u003cGJPluginTypeMapConfig\u003e getTypeMapConfigs() {\n        return List.of(\n            // Simple mapping: same-name fields auto-mapped\n            GJPluginTypeMapConfig.of(User.class, UserDTO.class),\n\n            // Custom mapping: explicit field mapping rules\n            GJPluginTypeMapConfig.of(User.class, UserResponse.class, typeMap -\u003e {\n                typeMap.addMapping(User::getId, UserResponse::setId);\n                typeMap.addMapping(User::getName, UserResponse::setUserName);\n                typeMap.addMapping(User::getEmail, UserResponse::setEmailAddress);\n            })\n        );\n    }\n}\n```\n\n### 8.2 Using ModelMapper\n\nThe framework builds and registers a `ModelMapper` bean automatically. Inject it directly:\n\n```java\n@Service\npublic class UserServiceImpl implements UserService {\n\n    private final ModelMapper modelMapper;\n    private final UserMapper userMapper;\n\n    public UserServiceImpl(ModelMapper modelMapper, UserMapper userMapper) {\n        this.modelMapper = modelMapper;\n        this.userMapper = userMapper;\n    }\n\n    @Override\n    public List\u003cUserDTO\u003e getList() {\n        return userMapper.selectList(Wrappers.lambdaQuery())\n                .stream()\n                .map(user -\u003e modelMapper.map(user, UserDTO.class))\n                .toList();\n    }\n}\n```\n\n### 8.3 Mapping Registration Mechanism\n\n- On plugin start, `GJPluginLifecycleManager` listens for `GJPluginStartedEvent`, scans all `GJPluginModelMapperConfig` beans from the plugin context\n- All `GJPluginTypeMapConfig` entries are merged: if the host application already has a `ModelMapper`, mappings are appended to the shared instance; otherwise the framework creates one\n- If a `TypeMap` already exists for a source/destination pair, the `merge` strategy is used (append, not replace)\n\n---\n\n## 9. Plugin Configuration Management\n\n### 9.1 Configuration Class\n\nUse `@ConfigurationProperties` to bind plugin-specific configuration:\n\n```java\npackage gj.module.user;\n\nimport lombok.Getter;\nimport lombok.Setter;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.stereotype.Component;\n\n@Setter\n@Getter\n@Component\n@ConfigurationProperties(prefix = \"gj.module.user\")\npublic class UserConfig {\n    private boolean enabled = true;\n    private String value;\n    private int maxRetry = 3;\n    private String apiUrl;\n}\n```\n\n### 9.2 Configuration File\n\nProvide values in `src/main/resources/{pluginId}.properties`:\n\n```properties\ngj.module.user.enabled=true\ngj.module.user.value=iot\ngj.module.user.max-retry=5\ngj.module.user.api-url=https://api.example.com\n```\n\n### 9.3 Injection and Usage\n\nAny Spring bean in the plugin can inject the configuration class:\n\n```java\n@Service\npublic class UserServiceImpl implements UserService {\n\n    private final UserConfig config;\n\n    public UserServiceImpl(UserConfig config) {\n        this.config = config;\n    }\n\n    public void doSomething() {\n        if (config.isEnabled()) {\n            String apiUrl = config.getApiUrl();\n            // ...\n        }\n    }\n}\n```\n\n### 9.4 Configuration Source Priority\n\nThe framework loads configuration with the following priority:\n1. Plugin container internal environment variables\n2. `{pluginId}.properties` file (loaded by `GJPluginLifecycle.registerResource()` into PropertySource)\n3. Main application environment variables (inherited from parent context as fallback)\n\n---\n\n## 10. Real-Time Communication\n\nPowered by [netty-socketio](https://github.com/mrniko/netty-socketio).\n\n### 10.1 Creating a Hub\n\nExtend `GJHub` and use `@GJHubMethod` to annotate message handler methods:\n\n```java\npackage gj.module.user.socketio;\n\nimport gj.pf4j.socketio.GJHub;\nimport gj.pf4j.socketio.GJHubMethod;\nimport org.springframework.stereotype.Component;\n\nimport java.util.concurrent.CompletableFuture;\n\n@Component\npublic class UserHub extends GJHub {\n\n    public UserHub() {\n        super(\"userHub\");  // hubName — clients route messages by this name\n    }\n\n    /**\n     * Called when a client connects\n     */\n    @Override\n    public CompletableFuture\u003cVoid\u003e onConnectedAsync() {\n        return CompletableFuture.runAsync(() -\u003e {\n            String connectionId = getContext().getConnectionId();\n            System.out.println(\"User connected: (\" + connectionId + \")\");\n        });\n    }\n\n    /**\n     * Called when a client disconnects\n     */\n    @Override\n    public CompletableFuture\u003cVoid\u003e onDisconnectedAsync() {\n        return CompletableFuture.runAsync(() -\u003e {\n            System.out.println(\"disconnected\");\n        });\n    }\n\n    /**\n     * Handle the \"sendMessage\" event from clients\n     */\n    @GJHubMethod(\"sendMessage\")\n    public void onSendMessage(MessageData data) {\n        // Broadcast to all clients except the sender\n        getClients().others().sendAsync(\"newMessage\", data);\n\n        // Send to a specific group\n        getClients().group(\"admin\").sendAsync(\"newMessage\", data);\n    }\n\n    /**\n     * Handle the \"joinGroup\" event from clients\n     */\n    @GJHubMethod(\"joinGroup\")\n    public void onJoinGroup(String groupName) {\n        getGroups().addToGroupAsync(groupName);\n    }\n}\n```\n\n### 10.2 Client Push API\n\n`getClients()` returns a `GJHubCallerClients` with the following targeting methods:\n\n```java\n// All connected clients\ngetClients().all().sendAsync(\"eventName\", data);\n\n// Only the caller\ngetClients().caller().sendAsync(\"eventName\", data);\n\n// Everyone except the caller\ngetClients().others().sendAsync(\"eventName\", data);\n\n// A specific connection\ngetClients().client(\"connectionId123\").sendAsync(\"eventName\", data);\n\n// A specific group\ngetClients().group(\"admin\").sendAsync(\"eventName\", data);\n\n// A specific user (by userId)\ngetClients().user(\"userId123\").sendAsync(\"eventName\", data);\n\n// A group excluding a specific user\ngetClients().groupExceptUser(\"admin\", \"excludedUserId\").sendAsync(\"eventName\", data);\n\n// All except certain connections\ngetClients().allExcept(List.of(\"connId1\", \"connId2\")).sendAsync(\"eventName\", data);\n```\n\n### 10.3 Group Management API\n\n`getGroups()` returns a `GJGroupManager`:\n\n```java\n// Join a group\ngetGroups().addToGroupAsync(\"groupName\");\n\n// Leave a group\ngetGroups().removeFromGroupAsync(\"groupName\");\n\n// Check group membership\ngetGroups().isInGroupAsync(\"groupName\").thenAccept(inGroup -\u003e {\n    System.out.println(\"In group: \" + inGroup);\n});\n\n// Get all groups for the current connection\ngetGroups().getGroupsForConnectionAsync().thenAccept(groups -\u003e {\n    System.out.println(\"My groups: \" + groups);\n});\n\n// Get all connection IDs in a group\ngetGroups().getConnectionsInGroupAsync(\"groupName\").thenAccept(connections -\u003e {\n    System.out.println(\"Connections in group: \" + connections);\n});\n```\n\n### 10.4 Hub Context\n\nRetrieve current connection information inside hub methods via `getContext()`:\n\n```java\nGJHubCallerContext ctx = getContext();\nString connectionId = ctx.getConnectionId();\nMap\u003cString, String\u003e queryParams = ctx.getQueryParams();\n```\n\n\u003e Frontend can pass custom parameters via the connection URL (e.g., `?hub=userHub\u0026userId=123`). Access them in the Hub via `ctx.getQueryParam(\"key\")`. Avoid passing plaintext sensitive information in the URL.\n\n### 10.5 Server-Side Configuration\n\nConfigure the Socket.IO server in the host application's configuration file as needed, for example:\n\n```properties\nsocketio.port=9600\nsocketio.maxConnectionsPerSecond=10\n```\n\nSee `GJSocketIOConfig` source for all available properties and their defaults.\n\n---\n\n## 11. Internationalization (i18n)\n\n### 11.1 Plugin i18n Files\n\nPlace `i18n/messages*.properties` in the plugin classpath:\n\n```\nsrc/main/resources/\n  i18n/\n    messages.properties          # Default\n    messages_zh_CN.properties    # Simplified Chinese\n    messages_en_US.properties    # English\n```\n\nExample `i18n/messages_en_US.properties`:\n\n```properties\nuser.list.title=User List\nuser.create.success=Created Successfully\nuser.delete.confirm=Confirm to delete this user?\n```\n\nExample `i18n/messages_zh_CN.properties`:\n\n```properties\nuser.list.title=用户列表\nuser.create.success=创建成功\nuser.delete.confirm=确认删除该用户？\n```\n\n### 11.2 Injection and Usage\n\n```java\n@RestController\n@RequestMapping(\"/api/v1/user\")\npublic class UserController {\n\n    private final MessageSource messageSource;\n\n    public UserController(MessageSource messageSource) {\n        this.messageSource = messageSource;\n    }\n\n    @GetMapping(\"/title\")\n    public String getTitle(Locale locale) {\n        return messageSource.getMessage(\"user.list.title\", null, locale);\n    }\n}\n```\n\n### 11.3 Fallback Mechanism\n\n- The framework creates a `GJPluginReloadableMessageSource` for each plugin (bean name: `plugin_i18n_{pluginId}`)\n- Key lookup: plugin's own messages first, then falls back to the main application's `messageSource`\n- If no match is found, returns the key itself (`useCodeAsDefaultMessage = true`)\n- 24-hour cache, UTF-8 encoding\n\n---\n\n## 12. Import/Export\n\nBuilt on [EasyExcel](https://easyexcel.opensource.alibaba.com/), provides `IImportManager` and `IExportManager` interfaces with multi-sheet read/write and automatic i18n header translation.\n\n### 12.1 Export Example\n\n```java\n@Service\npublic class UserExportService {\n\n    private final IExportManager exportManager;\n    private final UserMapper userMapper;\n\n    public UserExportService(IExportManager exportManager, UserMapper userMapper) {\n        this.exportManager = exportManager;\n        this.userMapper = userMapper;\n    }\n\n    /**\n     * Single-sheet export\n     */\n    public String exportUsers() throws IOException {\n        List\u003cUser\u003e users = userMapper.selectList(null);\n        return exportManager.exportToXlsx(users);\n    }\n\n    /**\n     * Multi-sheet export\n     */\n    public String exportMultiSheet() throws IOException {\n        Map\u003cString, List\u003c?\u003e\u003e sheets = new LinkedHashMap\u003c\u003e();\n        sheets.put(\"Users\", userMapper.selectList(null));\n        sheets.put(\"Roles\", roleMapper.selectList(null));\n        return exportManager.exportMultiSheetToXlsx(sheets);\n    }\n\n    /**\n     * Export to byte stream (for HTTP download responses)\n     */\n    public ByteArrayOutputStream exportToStream() throws IOException {\n        List\u003cUser\u003e users = userMapper.selectList(null);\n        return exportManager.exportToStream(users);\n    }\n}\n```\n\n### 12.2 Import Example\n\n```java\n@Service\npublic class UserImportService {\n\n    private final IImportManager importManager;\n\n    public UserImportService(IImportManager importManager) {\n        this.importManager = importManager;\n    }\n\n    /**\n     * Multi-sheet import\n     */\n    public void importUsers(InputStream inputStream) {\n        List\u003cList\u003cObject\u003e\u003e sheets = importManager.importFromXlsx(\n                \"users.xlsx\",\n                inputStream,\n                User.class,    // Sheet 0 → User entity\n                Role.class     // Sheet 1 → Role entity\n        );\n\n        List\u003cObject\u003e userRows = sheets.get(0);  // User sheet\n        List\u003cObject\u003e roleRows = sheets.get(1);  // Role sheet\n\n        // Process imported data...\n    }\n}\n```\n\n### 12.3 Header i18n\n\nEasyExcel `@ExcelProperty` annotation values are automatically translated via i18n during both import and export. The framework overrides `SimpleWriteHandler` and `ReadEventListener` to ensure generated and parsed Excel headers match the current locale.\n\n```java\n@Data\npublic class UserExcelVO {\n    @ExcelProperty(\"user.excel.name\")    // i18n key\n    private String name;\n\n    @ExcelProperty(\"user.excel.email\")\n    private String email;\n}\n```\n\n---\n\n## 13. Scheduled Tasks\n\nPowered by [Quartz](https://www.quartz-scheduler.org/). Plugins simply implement the `IPluginJob` interface and annotate it with `@PluginJob` — the framework automatically scans and registers them with the Quartz scheduler after the plugin starts.\n\n### 13.1 Dependency Note\n\nThe framework includes built-in Quartz support (`org.quartz-scheduler:quartz`) and auto-creates a `Scheduler` bean via `GJQuartzConfig` (`@ConditionalOnMissingBean`). The host application does not need to add any Quartz dependency. If the host app already has a custom `Scheduler` bean, the framework reuses it automatically.\n\n### 13.2 Creating a Scheduled Job\n\nCreate a bean implementing `IPluginJob` in your plugin and annotate it with `@PluginJob`:\n\n```java\npackage gj.module.user.job;\n\nimport gj.pf4j.quartzjob.IPluginJob;\nimport gj.pf4j.quartzjob.annotation.PluginJob;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Component;\n\n@Slf4j\n@Component\n@PluginJob(name = \"cleanExpiredTokens\", intervalSeconds = 3600)\npublic class TokenCleanupJob implements IPluginJob {\n\n    @Override\n    public void execute() {\n        log.info(\"Cleaning expired tokens...\");\n        // business logic\n    }\n}\n```\n\n### 13.3 @PluginJob Parameters\n\n| Parameter | Type | Default | Description |\n|---|---|---|---|\n| `name` | String | **required** | Globally unique job identifier |\n| `intervalSeconds` | long | -1 | Fixed interval in seconds; mutually exclusive with `cronExpression` |\n| `cronExpression` | String | \"\" | Cron expression; mutually exclusive with `intervalSeconds` |\n| `runOnce` | boolean | false | Execute only once |\n| `disallowConcurrentExecution` | boolean | true | Disallow concurrent execution of the same job |\n\n### 13.4 Cron Expression Examples\n\n```java\n@PluginJob(name = \"dailyReport\", cronExpression = \"0 0 8 * * ?\")       // Every day at 8:00\n@PluginJob(name = \"weeklySync\", cronExpression = \"0 0 2 ? * MON\")       // Every Monday at 2:00\n@PluginJob(name = \"initData\", runOnce = true)                            // Run once on startup\n```\n\n### 13.5 Manual Trigger (Injecting Scheduler)\n\nFor scenarios requiring manual trigger in business logic, inject the Quartz `Scheduler` directly:\n\n```java\n@Service\npublic class ReportService {\n\n    private final Scheduler scheduler;\n\n    public ReportService(Scheduler scheduler) {\n        this.scheduler = scheduler;\n    }\n\n    public void triggerReport(String pluginId) throws SchedulerException {\n        scheduler.triggerJob(new JobKey(pluginId + \":dailyReport\", pluginId));\n    }\n}\n```\n\n---\n\n## 14. In-Process Event Bus\n\nThe framework provides a lightweight in-process event bus for decoupled inter-plugin communication. Listeners implement `GJPluginLocalEventListener\u003cT\u003e` to handle typed events, while event classes use `@EventName` for Ant-style wildcard pattern matching.\n\n### 14.1 Defining Events\n\nAnnotate event classes with `@EventName`; name components are dot-separated:\n\n```java\npackage gj.module.user.event;\n\nimport gj.pf4j.eventbus.EventName;\nimport lombok.Data;\n\n@Data\n@EventName(\"user.created\")\npublic class UserCreatedEvent {\n    private Long userId;\n    private String userName;\n}\n```\n\n### 14.2 Creating Listeners\n\nImplement `GJPluginLocalEventListener\u003cT\u003e` and annotate with `@Component` to register as a Spring bean:\n\n```java\npackage gj.module.user.listener;\n\nimport gj.module.user.event.UserCreatedEvent;\nimport gj.pf4j.eventbus.GJPluginLocalEventListener;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.stereotype.Component;\n\n@Slf4j\n@Component\npublic class UserCreatedListener implements GJPluginLocalEventListener\u003cUserCreatedEvent\u003e {\n\n    @Override\n    public void HandleEvent(UserCreatedEvent event) {\n        log.info(\"User created: {} ({})\", event.getUserName(), event.getUserId());\n        // send welcome email, initialize user data, etc.\n    }\n}\n```\n\n### 14.3 Publishing Events\n\nInject `GJPluginLocalEventBus` into any Spring bean:\n\n```java\n@Service\npublic class UserService {\n\n    private final GJPluginLocalEventBus eventBus;\n\n    public UserService(GJPluginLocalEventBus eventBus) {\n        this.eventBus = eventBus;\n    }\n\n    public void createUser(String name) {\n        // create user logic ...\n\n        // Synchronous — all listeners run on the current thread\n        eventBus.publish(new UserCreatedEvent(userId, name));\n\n        // Asynchronous — listeners execute in the thread pool\n        eventBus.publishAsync(new UserCreatedEvent(userId, name));\n    }\n}\n```\n\n### 14.4 Wildcard Matching\n\n`@EventName` supports Ant-style wildcards with `.` as the path separator:\n\n```java\n@EventName(\"user.*\")           // matches user.created, user.updated, etc.\n@EventName(\"order.cancelled\")   // exact match\n```\n\nMultiple listeners can match a single event; each listener executes independently.\n\n---\n\n## 15. OpenAPI Documentation\n\n### 15.1 Automatic Grouping\n\nThe framework automatically creates an independent `GroupedOpenApi` bean (SpringDoc) for each plugin that registers controllers. The group name follows the pattern `pluginGroupedOpenApi-{pluginId}`. In Swagger-UI, select the desired plugin from the top-right dropdown to view its API documentation.\n\n### 15.2 Controller Example (with Swagger Annotations)\n\n```java\n@RestController\n@RequestMapping(\"/api/v1/user\")\n@Tag(name = \"User Management\", description = \"User CRUD operations\")\npublic class UserController {\n\n    @GetMapping(\"/{id}\")\n    @Operation(summary = \"Get user by ID\")\n    public UserResponse getById(\n            @Parameter(description = \"User ID\") @PathVariable Integer id) {\n        return userService.getById(id);\n    }\n\n    @PostMapping(\"/create\")\n    @Operation(summary = \"Create a new user\")\n    public boolean create(\n            @Parameter(description = \"Create request\") @RequestBody UserCreateRequest request) {\n        return userService.create(request);\n    }\n}\n```\n\n### 15.3 Access URL\n\nVisit `http://localhost:{port}/swagger-ui/index.html` after startup.\n\n---\n\n## 16. Plugin Packaging \u0026 Deployment\n\n### 16.1 Build the Plugin\n\n```bash\ncd user-plugin\nmvn clean package\n```\n\n### 16.2 Output Directory Structure\n\nAfter a successful build, `target/plugins/{artifactId}/` contains:\n\n```\ntarget/plugins/gj.module.user/\n├── gj.module.user-1.0.0-SNAPSHOT.jar     # Plugin main JAR\n├── gj.module.user.json                   # Plugin descriptor file\n└── lib/                                   # Plugin-private third-party dependency JARs\n    ├── some-third-party.jar\n    └── ...\n```\n\n### 16.3 MANIFEST.MF\n\n```manifest\nPlugin-Id: gj.module.user\nPlugin-Version: 1.0.0-SNAPSHOT\nClass-Path: lib/some-third-party.jar lib/another-lib.jar\n```\n\n### 16.4 Deploy to the Host Application\n\nCopy the entire `target/plugins/gj.module.user/` directory into the host application's `plugins/` directory:\n\n```\nHost application root/       ← current working directory in dev/debug mode\n  plugins/\n    gj.module.user/\n      gj.module.user-1.0.0-SNAPSHOT.jar\n      gj.module.user.json\n      lib/\n        ...\n    gj.module.other/\n      ...\n```\n\nIn production (non-dev/debug profiles), the plugin directory is located at `plugins/` under the Spring Boot JAR's `ApplicationHome` directory.\n\n### 16.5 Version Management\n\n`GJJarPluginRepository` scans each plugin directory, parses version numbers from JAR filenames (format: `{pluginId}-{version}.jar`), and loads the latest version. When multiple versions exist in a directory, only the highest version is loaded, with a log entry recording the selection.\n\n---\n\n## 17. Runtime Plugin Management API\n\n### 17.1 Injecting GJPluginService\n\n```java\n@RestController\n@RequestMapping(\"/api/admin/plugins\")\npublic class PluginAdminController {\n\n    private final GJPluginService pluginService;\n\n    public PluginAdminController(GJPluginService pluginService) {\n        this.pluginService = pluginService;\n    }\n\n    // ... management endpoints\n}\n```\n\n### 17.2 Load and Start All Plugins\n\n```java\n@PostMapping(\"/load-all\")\npublic void loadAndStartAll() {\n    pluginService.loadAndStartAllPlugins();\n}\n```\n\n### 17.3 Start a Single Plugin\n\n```java\n@PostMapping(\"/{pluginId}/start\")\npublic String startPlugin(@PathVariable String pluginId) {\n    PluginState state = pluginService.startPlugin(pluginId);\n    return \"Plugin \" + pluginId + \" state: \" + state;\n}\n```\n\n\u003e Dependencies are automatically resolved — dependent plugins are started first.\n\n### 17.4 Stop a Single Plugin\n\n```java\n@PostMapping(\"/{pluginId}/stop\")\npublic String stopPlugin(@PathVariable String pluginId) {\n    PluginState state = pluginService.stopPlugin(pluginId);\n    return \"Plugin \" + pluginId + \" state: \" + state;\n}\n```\n\n\u003e Reverse dependencies are stopped first before stopping the target plugin.\n\n### 17.5 Restart a Single Plugin\n\n```java\n@PostMapping(\"/{pluginId}/restart\")\npublic String restartPlugin(@PathVariable String pluginId) {\n    PluginState state = pluginService.restartPlugin(pluginId);\n    return \"Plugin \" + pluginId + \" state: \" + state;\n}\n```\n\n### 17.6 Hot-Unload / Hot-Reload a Plugin\n\n```java\n// Hot-unload (remove from memory without deleting files)\n@DeleteMapping(\"/{pluginId}/unload\")\npublic String unloadPlugin(@PathVariable String pluginId) {\n    boolean success = pluginService.unloadPlugin(pluginId);\n    return success ? \"Unloaded\" : \"Failed\";\n}\n\n// Hot-reload (re-discover from filesystem and load)\n@PostMapping(\"/{pluginId}/reload\")\npublic String reloadPlugin(@PathVariable String pluginId) {\n    PluginState state = pluginService.reloadPlugin(pluginId);\n    return \"Plugin \" + pluginId + \" state: \" + state;\n}\n```\n\n### 17.7 Reload All Plugins\n\n```java\n@PostMapping(\"/reload-all\")\npublic void reloadAll() {\n    pluginService.reloadAll();  // stop all → unload all → load all → start all\n}\n```\n\n### 17.8 Delete a Plugin\n\n```java\n@DeleteMapping(\"/{pluginId}\")\npublic String deletePlugin(@PathVariable String pluginId) {\n    boolean deleted = pluginService.deletePlugin(pluginId);\n    return deleted ? \"Deleted\" : \"Failed\";\n}\n```\n\n---\n\n## 18. Appendix: Host Application Integration\n\n### 18.1 Version Compatibility\n\ngj-pf4j depends on Spring core libraries (spring-webmvc, spring-beans, spring-jdbc, etc.) but does **not lock version numbers**. The framework publishes a `gj-dependencies` BOM for unified version management. By importing both gj BOM and Spring Boot BOM with the correct priority order, Spring versions automatically follow the developer's chosen Spring Boot version, avoiding conflicts.\n\n### Importing via BOM (Recommended)\n\nIn `dependencyManagement`, import the gj BOM followed by the Spring Boot BOM — **Spring Boot BOM goes last for higher priority**, ensuring Spring versions follow the developer's chosen Spring Boot version. The gj BOM only covers dependencies not managed by Spring Boot (e.g., PF4J, netty-socketio):\n\n```xml\n\u003cdependencyManagement\u003e\n    \u003cdependencies\u003e\n        \u003cdependency\u003e\n            \u003cgroupId\u003eio.github.wangpengxpy\u003c/groupId\u003e\n            \u003cartifactId\u003egj-dependencies\u003c/artifactId\u003e\n            \u003cversion\u003e1.0.3\u003c/version\u003e\n            \u003ctype\u003epom\u003c/type\u003e\n            \u003cscope\u003eimport\u003c/scope\u003e\n        \u003c/dependency\u003e\n        \u003c!-- Spring Boot BOM goes last — overrides gj BOM for overlapping keys --\u003e\n        \u003cdependency\u003e\n            \u003cgroupId\u003eorg.springframework.boot\u003c/groupId\u003e\n            \u003cartifactId\u003espring-boot-dependencies\u003c/artifactId\u003e\n            \u003cversion\u003e${spring-boot.version}\u003c/version\u003e\n            \u003ctype\u003epom\u003c/type\u003e\n            \u003cscope\u003eimport\u003c/scope\u003e\n        \u003c/dependency\u003e\n    \u003c/dependencies\u003e\n\u003c/dependencyManagement\u003e\n```\n\nThen add gj-pf4j (version managed by gj BOM):\n\n```xml\n\u003cdependencies\u003e\n    \u003cdependency\u003e\n        \u003cgroupId\u003eio.github.wangpengxpy\u003c/groupId\u003e\n        \u003cartifactId\u003egj-pf4j\u003c/artifactId\u003e\n    \u003c/dependency\u003e\n\u003c/dependencies\u003e\n```\n\n**Version Resolution:**\n\n| Developer's Spring Boot | gj BOM Spring Version | Effective Version |\n|---|---|---|\n| 3.5.x | 3.5.5 | 3.5.x (SB BOM overrides) |\n| 4.0.x | 3.5.5 | 4.0.x (SB BOM overrides) |\n\nDependencies in gj BOM that overlap with Spring Boot (spring-webmvc, spring-beans, etc.) are overridden by SB BOM. The gj BOM only governs dependencies not covered by SB BOM (pf4j, netty-socketio, modelmapper, etc.).\n\n### Direct Dependency (Not Recommended)\n\nYou can also skip the BOM and depend on gj-pf4j directly, but you must ensure Spring version compatibility yourself:\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.wangpengxpy\u003c/groupId\u003e\n    \u003cartifactId\u003egj-pf4j\u003c/artifactId\u003e\n    \u003cversion\u003e1.0.5\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n### 18.2 Host Application Entry Point\n\n**Required:**\n\n```java\n@SpringBootApplication\n@ComponentScan(\"gj\")     // all framework beans reside under gj.pf4j — must be scanned\npublic class GJApplication {\n    public static void main(String[] args) {\n        SpringApplication.run(GJApplication.class, args);\n    }\n}\n```\n\n- The framework includes `GJPluginConfig` and `GJPluginWebFluxConfig`; both are auto-activated via `@ComponentScan(\"gj\")`.\n- Plugins under the `plugins/` directory are automatically loaded and started after the main application's `ContextRefreshedEvent` fires.\n\n\u003e For configuring shared ModelMapper mappings in the host app (base model packages), see [18.3](#183-optional-gjmodelmapperscan-shared-models).\n\n### Spring MVC Mode (Default)\n\nSpring Boot defaults to **MVC (Servlet) mode** — no extra configuration is needed. Simply include `spring-boot-starter-web` and `springdoc-openapi-starter-webmvc-ui`:\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003eorg.springframework.boot\u003c/groupId\u003e\n    \u003cartifactId\u003espring-boot-starter-web\u003c/artifactId\u003e\n\u003c/dependency\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eorg.springdoc\u003c/groupId\u003e\n    \u003cartifactId\u003espringdoc-openapi-starter-webmvc-ui\u003c/artifactId\u003e\n\u003c/dependency\u003e\n```\n\nNo need to set `WebApplicationType` (defaults to `SERVLET`):\n\n```java\n@SpringBootApplication\n@ComponentScan(\"gj\")\npublic class GJApplication {\n    public static void main(String[] args) {\n        SpringApplication.run(GJApplication.class, args);\n    }\n}\n```\n\ngj-pf4j automatically uses `GJPluginRequestMappingHandlerMapping` (MVC) — plugin `@RestController` routes are registered through the Servlet container.\n\n### Spring WebFlux Mode\n\nIf the host application uses a WebFlux reactive architecture, **two steps** are required:\n\n**1. Swap Dependencies**\n\n```xml\n\u003c!-- Do NOT use spring-boot-starter-web --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eorg.springframework.boot\u003c/groupId\u003e\n    \u003cartifactId\u003espring-boot-starter-webflux\u003c/artifactId\u003e\n\u003c/dependency\u003e\n\u003c!-- Replace webmvc-ui with webflux-ui --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eorg.springdoc\u003c/groupId\u003e\n    \u003cartifactId\u003espringdoc-openapi-starter-webflux-ui\u003c/artifactId\u003e\n\u003c/dependency\u003e\n```\n\n**2. Explicitly Set the Web Type**\n\n```java\n@SpringBootApplication\n@ComponentScan(\"gj\")\npublic class GJApplication {\n    public static void main(String[] args) {\n        new SpringApplicationBuilder(GJApplication.class)\n                .web(WebApplicationType.REACTIVE)\n                .run(args);\n    }\n}\n```\n\nOnce gj-pf4j detects a `GJPluginWebFluxRequestMappingHandlerMapping` bean, it automatically switches to WebFlux mode for plugin controller route registration.\n\n### Mode Comparison\n\n| | MVC Mode (Default) | WebFlux Mode |\n|---|---|---|\n| Web Container | Tomcat (Servlet) | Netty (Reactive) |\n| Dependency | `spring-boot-starter-web` | `spring-boot-starter-webflux` |\n| SpringDoc | `springdoc-openapi-starter-webmvc-ui` | `springdoc-openapi-starter-webflux-ui` |\n| WebApplicationType | Not set (defaults to SERVLET) | Explicit `.web(REACTIVE)` |\n| Plugin Controller Code | `@RestController` | `@RestController` (identical) |\n| Route Registration | `GJPluginRequestMappingHandlerMapping` | `GJPluginWebFluxRequestMappingHandlerMapping` |\n\n### 18.3 Optional: `@GJModelMapperScan` (Shared Models)\n\nWhen the host application has common base model packages (entities such as `User`, `Menu`, `Role`, plus their Mappers, DTOs, and ModelMapper mappings), add the `gj-modelmapper` artifact and use `@GJModelMapperScan` to register those mappings into a global `ModelMapper` bean. Business plugins inherit this shared instance via the parent context:\n\n```\nHost App\n  ├─ DataSource\n  ├─ SqlSessionFactory → UserMapper, MenuMapper, RoleMapper ...\n  ├─ ModelMapper (User→UserDTO, Menu→MenuDTO) ← @GJModelMapperScan\n  │\n  └─ [parent context] ── Plugin (inherits)\n       ├─ [inherits] ModelMapper — final ModelMapper mm → ready to use with all shared mappings\n       ├─ [inherits] UserMapper — final UserMapper um → query shared tables directly\n       ├─ [own] PluginMapper — query plugin-specific tables\n       └─ [appends] plugin-specific mappings — added to the shared ModelMapper automatically, no duplication\n```\n\nConfiguration:\n\n```xml\n\u003c!-- host application pom.xml --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.wangpengxpy\u003c/groupId\u003e\n    \u003cartifactId\u003egj-modelmapper\u003c/artifactId\u003e\n\u003c/dependency\u003e\n```\n\n```java\n@SpringBootApplication\n@ComponentScan(\"gj\")\n@GJModelMapperScan(\n    basePackages = \"your.app.model\",      // base model package\n    markerInterface = GJModelMapperConfig.class\n)\npublic class GJApplication { ... }\n```\n\nModel mapping config inside the base model package:\n\n```java\npackage your.app.model;\n\nimport gj.modelmapper.GJModelMapperConfig;\nimport gj.modelmapper.GJModelMapperTypeMapConfig;\nimport java.util.List;\n\npublic class AppModelMapperConfig implements GJModelMapperConfig {\n    @Override\n    public List\u003cGJModelMapperTypeMapConfig\u003e getTypeMapConfigs() {\n        return List.of(\n            GJModelMapperTypeMapConfig.of(User.class, UserDTO.class),\n            GJModelMapperTypeMapConfig.of(Menu.class, MenuDTO.class)\n        );\n    }\n}\n```\n\n\u003e **Key insight:** `@GJModelMapperScan` (host app) and plugin `GJPluginModelMapperConfig` are independent mechanisms. The former injects a global `ModelMapper` for the host; the latter is auto-discovered as a Spring bean by `GJPluginModelMapperRegistry` and appends mappings to the shared instance. If the host app does not configure `@GJModelMapperScan`, plugin ModelMapper still works — the framework creates one automatically.\n\n---\n\n## 19. Claude Code Integration\n\nThe framework ships with built-in [Claude Code](https://claude.ai/code) skills for AI-driven plugin development:\n\n```bash\n/gj-plugin-new \"user management plugin with CRUD, real-time push, scheduled cleanup\"\n```\n\n**New projects** (included automatically when generating from archetype):\n\n```bash\nmvn archetype:generate -DarchetypeGroupId=io.github.wangpengxpy -DarchetypeArtifactId=gj-archetype ...\n```\n\n**Existing projects**: copy from `tools/claude-skills/` into the project root:\n\n```bash\ngit clone --depth 1 https://github.com/wangpengxpy/gj.spring.pf4j.git /tmp/gj-pf4j\ncp -r /tmp/gj-pf4j/tools/claude-skills/* .claude/\n```\n\n\u003e Internally uses OpenSpec for requirements analysis and task decomposition, then delegates to the `gj-plugin` skill for code generation.\n\n---\n\n## 20. FAQ\n\n### Q1: Plugin startup fails with `plugin.id` mismatch error?\n\nThe `plugin.id` in `plugin.properties` must **exactly match** the plugin main class package name. For example, if `plugin.id=gj.module.user`, the plugin entry class must be in the `gj.module.user` package. Any mismatch throws an `IllegalStateException` at startup. See [Section 3.2](#32-pluginproperties).\n\n### Q2: Plugin failed to start / how to troubleshoot startup failure?\n\nCheck the logs for `[PF4J]` entries. Startup failures are recorded in `GJPluginStartingError` with the plugin ID and exception detail. Common causes:\n\n- **Missing JAR**: the plugin directory under `plugins/` must contain a JAR matching `{pluginId}-*.jar`\n- **Dependency conflict**: plugin brings a library version incompatible with the host app\n- **Bean wiring failure**: a `@Component` in the plugin fails to construct due to missing dependencies\n\nSee [Section 4](#4-plugin-lifecycle) for the full lifecycle flow.\n\n### Q3: SQL works in MySQL but fails on DM/PostgreSQL with \"invalid identifier\"?\n\nA column name likely conflicts with that database's reserved keywords (e.g., `order`, `comment`, `context`). Implement `GJTableKeywordProvider` and register it as a `@Component`:\n\n```java\n@Component\npublic class MyKeywords implements GJTableKeywordProvider {\n    @Override\n    public Map\u003cString, Set\u003cString\u003e\u003e getTableKeywords() {\n        return Map.of(\"table_name\", Set.of(\"order\", \"comment\"));\n    }\n}\n```\n\nThe framework automatically wraps these columns with the correct quote character at runtime. See [Section 6.6](#66-sql-keyword-quoting).\n\n### Q4: Host app has Controllers but they don't appear in Swagger-UI?\n\nThe framework auto-creates `GroupedOpenApi` for **plugins only**, not for the host app. Add a `default` group in `application.yml`:\n\n```yaml\nspringdoc:\n  group-configs:\n    - group: default\n      displayName: default\n      packagesToScan: com.example.controller\n```\n\n\u003e`packagesToScan` must point to the host application's Controller package, not a plugin package. See [Section 15](#15-openapi-documentation).\n\n### Q5: What is the minimum configuration for the host app?\n\nOnly one annotation is required:\n\n```java\n@SpringBootApplication\n@ComponentScan(\"gj\")   // activates all framework beans\npublic class GJApplication {\n    public static void main(String[] args) {\n        SpringApplication.run(GJApplication.class, args);\n    }\n}\n```\n\nWithout `@ComponentScan(\"gj\")`, no framework beans are discovered and the entire framework stays inactive. See [Section 18.2](#182-host-application-entry-point).\n\n### Q6: How to share ModelMapper mappings between host app and plugins?\n\nAdd the `gj-modelmapper` dependency and use `@GJModelMapperScan` on the host app:\n\n```java\n@GJModelMapperScan(\n    basePackages = \"com.example.model\",\n    markerInterface = GJModelMapperConfig.class\n)\n```\n\nPlugins append their own mappings to the shared `ModelMapper` instance automatically. See [Section 18.3](#183-optional-gjmodelmapperscan-shared-models).\n\n### Q7: Does auto-migration ever drop tables or columns?\n\nNo. The migration engine follows a **strict additive-only policy** — only `CREATE TABLE` (when table is missing) and `ALTER TABLE ADD COLUMN` (when column is missing) are generated. Existing tables, columns, and data are never modified or deleted. See [Section 7.2](#72-production-safety).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwangpengxpy%2Fgj.spring.pf4j","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwangpengxpy%2Fgj.spring.pf4j","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwangpengxpy%2Fgj.spring.pf4j/lists"}