diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 0000000..b9cfd51
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,5 @@
+{
+ "general": {
+ "previewFeatures": true
+ }
+}
\ No newline at end of file
diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml
index 7a62475..dfb8b9d 100644
--- a/.github/workflows/native-image.yml
+++ b/.github/workflows/native-image.yml
@@ -3,6 +3,7 @@ name: native-image
on:
release:
types: [published]
+ workflow_dispatch:
permissions:
contents: write
@@ -17,23 +18,31 @@ jobs:
matrix:
include:
- os: ubuntu-latest
- artifact: linux
+ artifact: linux-amd64
+ arch: amd64
+ ext: ""
+ - os: ubuntu-24.04-arm
+ artifact: linux-arm64
+ arch: arm64
ext: ""
- os: macos-latest
artifact: macos
+ arch: macos
ext: ""
- os: windows-latest
artifact: windows
+ arch: windows
ext: ".exe"
steps:
- name: Checkout
uses: actions/checkout@v4
+
- name: Set up Liberica NIK
uses: graalvm/setup-graalvm@v1
with:
java-version: '25'
- distribution: 'liberica' # 这里指定使用 Liberica 发行版
+ distribution: 'liberica'
github-token: ${{ secrets.GITHUB_TOKEN }}
cache: 'maven'
@@ -65,102 +74,45 @@ jobs:
npm install
npm run build
cd ..
- mkdir -p src/main/resources/static
- cp -r push-server-web/dist/* src/main/resources/static/
-
- - name: Run tests
- shell: bash
- run: ${{ env.MVN_CMD }} -B test
+ mkdir -p push-server-core/src/main/resources/static
+ cp -r push-server-web/dist/* push-server-core/src/main/resources/static/
- name: Build native image
shell: bash
- run: ${{ env.MVN_CMD }} -B -Pnative -DskipTests native:compile
+ run: ${{ env.MVN_CMD }} -B -pl push-server-core -am -Pnative -DskipTests native:compile
- name: Rename binary
shell: bash
run: |
- src="target/push-server${{ matrix.ext }}"
- dest="target/push-server-${{ matrix.artifact }}${{ matrix.ext }}"
+ mkdir -p target-bin
+ src="push-server-core/target/push-server-core${{ matrix.ext }}"
+ dest="target-bin/push-server-${{ matrix.artifact }}${{ matrix.ext }}"
if [ -f "$src" ]; then
mv "$src" "$dest"
- fi
-
- - name: Upload release asset
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{ github.ref_name }}
- files: target/push-server-${{ matrix.artifact }}${{ matrix.ext }}
-
- docker-build:
- name: docker build (${{ matrix.arch }})
- runs-on: ${{ matrix.os }}
- needs: build
- strategy:
- fail-fast: false
- matrix:
- include:
- - os: ubuntu-latest
- arch: amd64
- - os: ubuntu-24.04-arm
- arch: arm64
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up Liberica NIK
- uses: graalvm/setup-graalvm@v1
- with:
- java-version: '25'
- distribution: 'liberica'
- github-token: ${{ secrets.GITHUB_TOKEN }}
- cache: 'maven'
-
- - name: Select Maven command
- shell: bash
- run: |
- if [ -f ./mvnw ]; then
- chmod +x ./mvnw
- echo "MVN_CMD=./mvnw" >> "$GITHUB_ENV"
else
- echo "MVN_CMD=mvn" >> "$GITHUB_ENV"
+ echo "Error: Binary not found at $src"
+ exit 1
fi
- - name: Checkout Frontend
- uses: actions/checkout@v4
- with:
- repository: qingzhou-dev/push-server-web
- path: push-server-web
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
-
- - name: Build Frontend
- shell: bash
- run: |
- cd push-server-web
- npm install
- npm run build
- cd ..
- mkdir -p src/main/resources/static
- cp -r push-server-web/dist/* src/main/resources/static/
-
- - name: Build native image
- shell: bash
- run: ${{ env.MVN_CMD }} -B -Pnative -DskipTests native:compile
-
- - name: Upload binary
+ - name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: push-server-${{ matrix.arch }}
- path: target/push-server
+ name: push-server-${{ matrix.artifact }}
+ path: target-bin/push-server-${{ matrix.artifact }}${{ matrix.ext }}
if-no-files-found: error
+ - name: Upload release asset
+ if: github.event_name == 'release'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ files: target-bin/push-server-${{ matrix.artifact }}${{ matrix.ext }}
+
docker-push:
name: docker push
runs-on: ubuntu-latest
- needs: docker-build
+ needs: build
+ if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -168,20 +120,20 @@ jobs:
- name: Download amd64 binary
uses: actions/download-artifact@v4
with:
- name: push-server-amd64
+ name: push-server-linux-amd64
path: bin-amd64
- name: Download arm64 binary
uses: actions/download-artifact@v4
with:
- name: push-server-arm64
+ name: push-server-linux-arm64
path: bin-arm64
- name: Prepare binaries
run: |
mkdir -p target
- cp bin-amd64/push-server target/push-server-amd64
- cp bin-arm64/push-server target/push-server-arm64
+ cp bin-amd64/push-server-linux-amd64 target/push-server-amd64
+ cp bin-arm64/push-server-linux-arm64 target/push-server-arm64
ls -R target
- name: Set up Docker Buildx
@@ -219,4 +171,4 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/pom.xml b/pom.xml
index c532caa..a49cee4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,8 @@
dev.qingzhou
push-server
- 0.1.3
+ 0.1.4
+ pom
push-server
push-server
@@ -37,148 +38,54 @@
UTF-8
prod
-
-
- org.springframework.boot
- spring-boot-starter-validation
-
-
- org.springframework.boot
- spring-boot-starter-webmvc
-
-
- org.springframework.boot
- spring-boot-starter-jdbc
-
-
- com.baomidou
- mybatis-plus-spring-boot4-starter
- 3.5.15
-
-
- org.xerial
- sqlite-jdbc
- 3.46.0.0
-
-
- org.springframework.security
- spring-security-crypto
-
-
- org.springframework.boot
- spring-boot-starter-security
-
-
- org.springframework.boot
- spring-boot-starter-validation-test
- test
-
-
- org.springframework.boot
- spring-boot-starter-webmvc-test
- test
-
-
-
- com.github.ben-manes.caffeine
- caffeine
-
-
-
- dev.qingzhou
- push-core
- 1.0.0
-
-
- org.projectlombok
- lombok
- provided
-
-
-
-
-
-
- src/main/resources
- true
-
- application.yml
- application.properties
- **/*.json
- **/*.xml
-
-
-
-
- src/main/resources
- false
-
- static/**
- templates/**
-
-
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
- true
-
- org.projectlombok
- lombok
-
-
- org.springframework.boot
- spring-boot-configuration-processor
-
-
-
-
-
- org.springframework.boot
- spring-boot-maven-plugin
-
-
-
- repackage
-
-
-
-
-
- org.graalvm.buildtools
- native-maven-plugin
-
- dev.qingzhou.pushserver.PushServerApplication
- false
-
- -Djava.specification.version=25
- -march=compatibility
- --initialize-at-build-time=org.sqlite.util.ProcessRunner
- --initialize-at-build-time=org.sqlite.util.OSInfo
- --initialize-at-run-time=org.apache.ibatis
-
- --initialize-at-run-time=org.mybatis
- --initialize-at-run-time=org.apache.ibatis.logging
- --initialize-at-run-time=org.apache.ibatis.logging.LogFactory
-
-
-
-
- org.apache.maven.plugins
- maven-resources-plugin
-
-
- @
-
- false
-
-
-
-
+
+ push-server-api
+ push-server-core
+
+
+
+
+ com.baomidou
+ mybatis-plus-bom
+ 3.5.15
+ pom
+ import
+
+
+ com.baomidou
+ mybatis-plus-spring-boot4-starter
+ 3.5.15
+
+
+ org.xerial
+ sqlite-jdbc
+ 3.46.0.0
+
+
+ dev.qingzhou
+ push-core
+ 1.0.0
+
+
+ io.grpc
+ grpc-protobuf
+ 1.78.0
+
+
+
+ io.grpc
+ grpc-stub
+ 1.78.0
+
+
+ io.grpc
+ grpc-netty-shaded
+ 1.78.0
+
+
+
@@ -203,4 +110,4 @@
-
+
\ No newline at end of file
diff --git a/push-server-api/pom.xml b/push-server-api/pom.xml
new file mode 100644
index 0000000..6b41fa8
--- /dev/null
+++ b/push-server-api/pom.xml
@@ -0,0 +1,89 @@
+
+
+
+ push-server
+ dev.qingzhou
+ 0.1.3
+
+ 4.0.0
+
+ push-server-api
+
+
+ 25
+ 25
+ 1.78.0
+ 3.24.0
+
+
+
+
+
+ io.grpc
+ grpc-protobuf
+ compile
+
+
+
+ io.grpc
+ grpc-stub
+ compile
+
+
+ javax.annotation
+ javax.annotation-api
+ 1.3.2
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+
+
+
+ kr.motd.maven
+ os-maven-plugin
+ 1.7.1
+
+
+
+
+ org.xolstice.maven.plugins
+ protobuf-maven-plugin
+ 0.6.1
+
+ com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}
+ grpc-java
+ io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
+
+
+
+
+ compile
+ compile-custom
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ true
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/model/ActionContext.java b/push-server-api/src/main/java/dev/qingzhou/push/api/model/ActionContext.java
new file mode 100644
index 0000000..1d635ab
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/model/ActionContext.java
@@ -0,0 +1,25 @@
+package dev.qingzhou.push.api.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+@Builder
+public class ActionContext {
+ private String eventId;
+ private String appId;
+ private String userId;
+ private String userName;
+
+ private String type; // TEXT, CLICK
+ private String content; // 具体内容
+
+ // 运行时配置 (从平台透传而来,不要持久化)
+ private Map pluginConfig;
+
+ public String getConfig(String key) {
+ return pluginConfig != null ? pluginConfig.get(key) : null;
+ }
+}
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/model/ConfigField.java b/push-server-api/src/main/java/dev/qingzhou/push/api/model/ConfigField.java
new file mode 100644
index 0000000..e3a2aa4
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/model/ConfigField.java
@@ -0,0 +1,17 @@
+package dev.qingzhou.push.api.model;
+
+import lombok.Builder;
+import lombok.Data;
+import java.util.List;
+
+@Data
+@Builder
+public class ConfigField {
+ private String name;
+ private String label;
+ private ConfigType type;
+ private String defaultValue;
+ private boolean required;
+ private String description;
+ private List options;
+}
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/model/ConfigType.java b/push-server-api/src/main/java/dev/qingzhou/push/api/model/ConfigType.java
new file mode 100644
index 0000000..35a3794
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/model/ConfigType.java
@@ -0,0 +1,10 @@
+package dev.qingzhou.push.api.model;
+
+public enum ConfigType {
+ TEXT,
+ PASSWORD,
+ BOOLEAN,
+ SELECT,
+ NUMBER,
+ TEXTAREA
+}
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/model/PluginMeta.java b/push-server-api/src/main/java/dev/qingzhou/push/api/model/PluginMeta.java
new file mode 100644
index 0000000..a03fd76
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/model/PluginMeta.java
@@ -0,0 +1,16 @@
+package dev.qingzhou.push.api.model;
+
+import lombok.Builder;
+import lombok.Data;
+import java.util.List;
+
+@Data
+@Builder
+public class PluginMeta {
+ private String id;
+ private String version;
+ private String name;
+ private String description;
+ private int maxConcurrency;
+ private List configFields;
+}
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/model/PushMessage.java b/push-server-api/src/main/java/dev/qingzhou/push/api/model/PushMessage.java
new file mode 100644
index 0000000..4dae606
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/model/PushMessage.java
@@ -0,0 +1,37 @@
+package dev.qingzhou.push.api.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PushMessage {
+ private String appId;
+ private String targetUserId;
+ private String requestId;
+
+ private String type; // 消息类型: text, markdown, image, news 等
+ private String content; // 文本内容 / Markdown 内容
+
+ private String title; // 标题
+ private String url; // 跳转链接
+ private String mediaId; // 媒体ID (如图片/视频)
+
+ private List articles; // 图文列表
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class Article {
+ private String title;
+ private String description;
+ private String url;
+ private String picUrl;
+ }
+}
\ No newline at end of file
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/model/SelectOption.java b/push-server-api/src/main/java/dev/qingzhou/push/api/model/SelectOption.java
new file mode 100644
index 0000000..f1fc08f
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/model/SelectOption.java
@@ -0,0 +1,14 @@
+package dev.qingzhou.push.api.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class SelectOption {
+ private String label;
+ private String value;
+ private String description;
+}
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/spi/PushPlugin.java b/push-server-api/src/main/java/dev/qingzhou/push/api/spi/PushPlugin.java
new file mode 100644
index 0000000..a945897
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/spi/PushPlugin.java
@@ -0,0 +1,22 @@
+package dev.qingzhou.push.api.spi;
+
+import dev.qingzhou.push.api.model.ActionContext;
+import dev.qingzhou.push.api.model.PluginMeta;
+
+public interface PushPlugin {
+
+ // 获取插件元数据 (ID, Version, ConfigDefinition)
+ PluginMeta getMeta();
+
+ // 路由判断:决定是否处理该消息
+ boolean supports(ActionContext context);
+
+ // 初始化:注入发送器能力
+ void init(PushSender sender);
+
+ // 核心业务逻辑
+ // 约定:无返回值,处理结果必须通过 sender 异步发送
+ void handle(ActionContext context);
+
+ default void shutdown() {}
+}
diff --git a/push-server-api/src/main/java/dev/qingzhou/push/api/spi/PushSender.java b/push-server-api/src/main/java/dev/qingzhou/push/api/spi/PushSender.java
new file mode 100644
index 0000000..2da29ef
--- /dev/null
+++ b/push-server-api/src/main/java/dev/qingzhou/push/api/spi/PushSender.java
@@ -0,0 +1,11 @@
+package dev.qingzhou.push.api.spi;
+
+import dev.qingzhou.push.api.model.PushMessage;
+
+public interface PushSender {
+ /**
+ * 发送消息
+ * @param message 消息对象
+ */
+ void send(PushMessage message);
+}
\ No newline at end of file
diff --git a/push-server-api/src/main/proto/plugin_gateway.proto b/push-server-api/src/main/proto/plugin_gateway.proto
new file mode 100644
index 0000000..644d9a5
--- /dev/null
+++ b/push-server-api/src/main/proto/plugin_gateway.proto
@@ -0,0 +1,156 @@
+syntax = "proto3";
+
+package dev.qingzhou.push.api.grpc;
+
+option java_multiple_files = true;
+option java_package = "dev.qingzhou.push.api.grpc";
+option java_outer_classname = "PluginGatewayProto";
+
+// Service: 插件网关
+service PluginGateway {
+ // 建立双向流 (Metadata: Authorization: Bearer )
+ rpc Connect (stream PluginUpstreamPacket) returns (stream PlatformDownstreamPacket);
+}
+
+// --- 基础信封 (Observability) ---
+message PacketHeader {
+ string trace_id = 1; // 分布式追踪 ID
+ int64 timestamp = 2; // 时间戳
+ string plugin_key = 3; // 插件标识
+ int32 protocol_ver = 4; // 协议版本
+}
+
+// --- 顶层包 ---
+message PluginUpstreamPacket {
+ PacketHeader header = 1;
+ oneof payload {
+ RegisterRequest register = 2;
+ Heartbeat heartbeat = 3;
+ ActionAck action_ack = 4; // 任务回执
+ PushRequest push_request = 5; // 主动推送
+ }
+}
+
+message PlatformDownstreamPacket {
+ PacketHeader header = 1;
+ oneof payload {
+ RegisterResponse register_ack = 2;
+ UserActionEvent action_event = 3; // 下发任务
+ PushResponse push_response = 4; // 推送确认
+ }
+}
+
+// --- 1. 注册与配置元数据 ---
+enum ConfigType {
+ CONFIG_TYPE_UNSPECIFIED = 0;
+ CONFIG_TYPE_TEXT = 1;
+ CONFIG_TYPE_PASSWORD = 2;
+ CONFIG_TYPE_BOOLEAN = 3;
+ CONFIG_TYPE_SELECT = 4;
+ CONFIG_TYPE_NUMBER = 5;
+ CONFIG_TYPE_TEXTAREA = 6;
+}
+
+message SelectOption {
+ string value = 1;
+ string label = 2;
+ string description = 3;
+}
+
+message ConfigDefinition {
+ string name = 1;
+ string label = 2;
+ ConfigType type = 3;
+ string default_value = 4;
+ bool required = 5;
+ string description = 6;
+ repeated SelectOption options = 7;
+}
+
+message RegisterRequest {
+ string plugin_key = 1;
+ string plugin_version = 2;
+ repeated string commands = 3; // 路由提示
+ int32 max_concurrency = 4; // 流控声明
+ repeated ConfigDefinition config_definitions = 5;
+}
+
+message RegisterResponse {
+ bool success = 1;
+ string reason = 2;
+ int32 server_protocol_ver = 3;
+}
+
+// --- 2. 心跳 ---
+message Heartbeat {
+ int32 current_inflight = 1; // 当前负载
+ int64 uptime_seconds = 2;
+}
+
+// --- 3. 任务下发 ---
+enum UserActionType {
+ USER_ACTION_TYPE_UNSPECIFIED = 0;
+ USER_ACTION_TYPE_TEXT = 1;
+ USER_ACTION_TYPE_CLICK = 2;
+ USER_ACTION_TYPE_IMAGE = 3;
+}
+
+message UserActionEvent {
+ string event_id = 1;
+ string app_id = 2;
+ string user_id = 3;
+ string user_name = 4;
+ UserActionType type = 5;
+ string content = 6;
+ string channel_source = 7;
+ map plugin_config = 8; // 运行时配置注入
+}
+
+// --- 4. 可靠性回执 ---
+enum AckStatus {
+ ACK_STATUS_RECEIVED = 0;
+ ACK_STATUS_PROCESSING = 1;
+ ACK_STATUS_SUCCESS = 2;
+ ACK_STATUS_FAILED = 3;
+}
+
+message ActionAck {
+ string event_id = 1;
+ AckStatus status = 2;
+ string message = 3;
+}
+
+// --- 5. 消息推送 ---
+enum PushContentType {
+ PUSH_CONTENT_TYPE_TEXT = 0;
+ PUSH_CONTENT_TYPE_MARKDOWN = 1;
+ PUSH_CONTENT_TYPE_IMAGE = 2;
+ PUSH_CONTENT_TYPE_NEWS = 3;
+ PUSH_CONTENT_TYPE_TEXT_CARD = 4;
+}
+
+message PushArticle {
+ string title = 1;
+ string description = 2;
+ string url = 3;
+ string pic_url = 4;
+}
+
+message PushRequest {
+ string request_id = 1;
+ string app_id = 2;
+ string target_user_id = 3;
+ PushContentType type = 4;
+ string content = 5;
+
+ string title = 6;
+ string url = 7;
+ string media_id = 8;
+ repeated PushArticle articles = 9;
+}
+
+message PushResponse {
+ bool success = 1;
+ string error_code = 2;
+ string error_msg = 3;
+}
diff --git a/push-server-core/pom.xml b/push-server-core/pom.xml
new file mode 100644
index 0000000..445f5b9
--- /dev/null
+++ b/push-server-core/pom.xml
@@ -0,0 +1,166 @@
+
+
+
+ push-server
+ dev.qingzhou
+ 0.1.3
+
+ 4.0.0
+
+ push-server-core
+
+
+
+ dev.qingzhou
+ push-server-api
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ com.baomidou
+ mybatis-plus-spring-boot4-starter
+
+
+ org.xerial
+ sqlite-jdbc
+
+
+ org.springframework.security
+ spring-security-crypto
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+ com.baomidou
+ mybatis-plus-jsqlparser
+
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+
+
+
+ dev.qingzhou
+ push-core
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ io.grpc
+ grpc-netty-shaded
+ 1.78.0
+
+
+
+
+
+
+ src/main/resources
+ true
+
+ application.yml
+ application.properties
+ **/*.json
+ **/*.xml
+
+
+
+
+ src/main/resources
+ false
+
+ static/**
+ templates/**
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ true
+
+ org.projectlombok
+ lombok
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+ org.graalvm.buildtools
+ native-maven-plugin
+
+ dev.qingzhou.pushserver.PushServerApplication
+ false
+
+ -Djava.specification.version=25
+ -march=compatibility
+ --initialize-at-build-time=org.sqlite.util.ProcessRunner
+ --initialize-at-build-time=org.sqlite.util.OSInfo
+ --initialize-at-run-time=org.apache.ibatis
+ --initialize-at-run-time=io.grpc.netty.shaded.io.netty --initialize-at-run-time=org.mybatis
+ --initialize-at-run-time=org.apache.ibatis.logging
+ --initialize-at-run-time=org.apache.ibatis.logging.LogFactory
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ @
+
+ false
+
+
+
+
+
diff --git a/src/main/java/dev/qingzhou/pushserver/PushServerApplication.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/PushServerApplication.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/PushServerApplication.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/PushServerApplication.java
diff --git a/src/main/java/dev/qingzhou/pushserver/aspect/SecurityInterceptor.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/aspect/SecurityInterceptor.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/aspect/SecurityInterceptor.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/aspect/SecurityInterceptor.java
diff --git a/src/main/java/dev/qingzhou/pushserver/common/PortalResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/common/PortalResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/common/PortalResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/common/PortalResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/common/PortalSessionKeys.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/common/PortalSessionKeys.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/common/PortalSessionKeys.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/common/PortalSessionKeys.java
diff --git a/src/main/java/dev/qingzhou/pushserver/common/PortalSessionSupport.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/common/PortalSessionSupport.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/common/PortalSessionSupport.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/common/PortalSessionSupport.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/config/GrpcServerConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/GrpcServerConfig.java
new file mode 100644
index 0000000..649c70c
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/GrpcServerConfig.java
@@ -0,0 +1,55 @@
+package dev.qingzhou.pushserver.config;
+
+import dev.qingzhou.pushserver.grpc.PluginAuthInterceptor;
+import dev.qingzhou.pushserver.grpc.PluginGatewayImpl;
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Configuration
+@RequiredArgsConstructor
+public class GrpcServerConfig {
+
+ private final PluginGatewayImpl pluginGateway;
+ private final PluginAuthInterceptor authInterceptor;
+
+ @Value("${grpc.server.port:9090}")
+ private int port;
+
+ private Server server;
+
+ @PostConstruct
+ public void start() throws IOException {
+ server = ServerBuilder.forPort(port)
+ .addService(io.grpc.ServerInterceptors.intercept(pluginGateway, authInterceptor))
+ .build()
+ .start();
+ log.info("gRPC Server started, listening on {}", port);
+ }
+
+ @PreDestroy
+ public void stop() {
+ if (server != null) {
+ log.info("Shutting down gRPC Server...");
+ server.shutdown();
+ try {
+ if (!server.awaitTermination(30, TimeUnit.SECONDS)) {
+ server.shutdownNow();
+ server.awaitTermination(5, TimeUnit.SECONDS);
+ }
+ } catch (InterruptedException e) {
+ server.shutdownNow();
+ }
+ log.info("gRPC Server stopped.");
+ }
+ }
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/config/JsonDtoPackageHints.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/JsonDtoPackageHints.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/JsonDtoPackageHints.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/JsonDtoPackageHints.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/MyBatisNativeConfiguration.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/MyBatisNativeConfiguration.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/MyBatisNativeConfiguration.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/MyBatisNativeConfiguration.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalAdminInitializer.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalAdminInitializer.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalAdminInitializer.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalAdminInitializer.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalDataSourceProperties.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalDataSourceProperties.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalDataSourceProperties.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalDataSourceProperties.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalDatabaseConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalDatabaseConfig.java
similarity index 83%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalDatabaseConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalDatabaseConfig.java
index 74560f4..04178dc 100644
--- a/src/main/java/dev/qingzhou/pushserver/config/PortalDatabaseConfig.java
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalDatabaseConfig.java
@@ -1,6 +1,8 @@
package dev.qingzhou.pushserver.config;
+import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.zaxxer.hikari.HikariDataSource;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -30,7 +32,9 @@ public DataSource dataSource(PortalDataSourceProperties properties) {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
- return new MybatisPlusInterceptor();
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.SQLITE));
+ return interceptor;
}
private String buildSqliteUrl(String filePath) {
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalJacksonConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalJacksonConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalJacksonConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalJacksonConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalMybatisConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalMybatisConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalMybatisConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalMybatisConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalSchemaInitializer.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalSchemaInitializer.java
similarity index 66%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalSchemaInitializer.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalSchemaInitializer.java
index 4f9f586..db0da98 100644
--- a/src/main/java/dev/qingzhou/pushserver/config/PortalSchemaInitializer.java
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalSchemaInitializer.java
@@ -92,11 +92,6 @@ CREATE TABLE IF NOT EXISTS v2_proxy_config (
CREATE UNIQUE INDEX IF NOT EXISTS idx_v2_app_api_key_hash
ON v2_app_api_key(api_key_hash)
""");
- List alterStatements = new ArrayList<>();
- alterStatements.add("ALTER TABLE v2_app_api_key ADD COLUMN api_key_plain TEXT NOT NULL DEFAULT ''");
- alterStatements.add("ALTER TABLE v2_app_api_key ADD COLUMN rate_limit_per_minute INTEGER NOT NULL DEFAULT 0");
- alterStatements.add("ALTER TABLE v2_wecom_app ADD COLUMN token TEXT");
- alterStatements.add("ALTER TABLE v2_wecom_app ADD COLUMN encoding_aes_key TEXT");
statements.add("""
CREATE TABLE IF NOT EXISTS v2_message_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -118,6 +113,69 @@ CREATE TABLE IF NOT EXISTS v2_message_log (
created_at INTEGER NOT NULL
)
""");
+ statements.add("""
+ CREATE TABLE IF NOT EXISTS v2_plugin (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plugin_key TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ description TEXT,
+ token TEXT NOT NULL,
+ status INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )
+ """);
+ statements.add("""
+ CREATE TABLE IF NOT EXISTS v2_app_plugin_config (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id INTEGER NOT NULL,
+ plugin_key TEXT NOT NULL,
+ config_json TEXT,
+ status INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(app_id, plugin_key)
+ )
+ """);
+ statements.add("""
+ CREATE TABLE IF NOT EXISTS v2_plugin_action_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plugin_key TEXT NOT NULL,
+ event_id TEXT,
+ status INTEGER,
+ message TEXT,
+ app_id TEXT,
+ app_name TEXT,
+ user_id TEXT,
+ type TEXT,
+ content TEXT,
+ plugin_config TEXT,
+ created_at INTEGER NOT NULL,
+ UNIQUE(plugin_key, event_id)
+ )
+ """);
+ statements.add("""
+ CREATE TABLE IF NOT EXISTS v2_plugin_heartbeat_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plugin_key TEXT NOT NULL,
+ current_inflight INTEGER,
+ uptime_seconds INTEGER,
+ created_at INTEGER NOT NULL
+ )
+ """);
+
+ List alterStatements = new ArrayList<>();
+ alterStatements.add("ALTER TABLE v2_app_api_key ADD COLUMN api_key_plain TEXT NOT NULL DEFAULT ''");
+ alterStatements.add("ALTER TABLE v2_app_api_key ADD COLUMN rate_limit_per_minute INTEGER NOT NULL DEFAULT 0");
+ alterStatements.add("ALTER TABLE v2_wecom_app ADD COLUMN token TEXT");
+ alterStatements.add("ALTER TABLE v2_wecom_app ADD COLUMN encoding_aes_key TEXT");
+ alterStatements.add("ALTER TABLE v2_plugin_action_log ADD COLUMN app_id TEXT");
+ alterStatements.add("ALTER TABLE v2_plugin_action_log ADD COLUMN user_id TEXT");
+ alterStatements.add("ALTER TABLE v2_plugin_action_log ADD COLUMN type TEXT");
+ alterStatements.add("ALTER TABLE v2_plugin_action_log ADD COLUMN content TEXT");
+ alterStatements.add("ALTER TABLE v2_plugin_action_log ADD COLUMN plugin_config TEXT");
+ alterStatements.add("ALTER TABLE v2_plugin_action_log ADD COLUMN app_name TEXT");
+ alterStatements.add("CREATE UNIQUE INDEX IF NOT EXISTS idx_v2_plugin_action_log_key_event ON v2_plugin_action_log(plugin_key, event_id)");
try (Connection connection = dataSource.getConnection()) {
try (Statement statement = connection.createStatement()) {
@@ -128,7 +186,7 @@ CREATE TABLE IF NOT EXISTS v2_message_log (
try {
statement.execute(sql);
} catch (Exception ignored) {
- // Column may already exist; ignore migration errors to stay backward compatible.
+ // Column/index may already exist; ignore to stay backward compatible.
}
}
}
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalSecurityConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalSecurityConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalSecurityConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalSecurityConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PortalWecomProperties.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalWecomProperties.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PortalWecomProperties.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PortalWecomProperties.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PushConfiguration.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PushConfiguration.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PushConfiguration.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PushConfiguration.java
diff --git a/src/main/java/dev/qingzhou/pushserver/config/PushProperties.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/PushProperties.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/PushProperties.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/PushProperties.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/config/SchedulingConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/SchedulingConfig.java
new file mode 100644
index 0000000..cb0915d
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/SchedulingConfig.java
@@ -0,0 +1,9 @@
+package dev.qingzhou.pushserver.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/config/WebConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/config/WebConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/config/WebConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/config/WebConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/CaptchaController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/CaptchaController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/CaptchaController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/CaptchaController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/DashboardController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/DashboardController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/DashboardController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/DashboardController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PageController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PageController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PageController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PageController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalAppController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAppController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalAppController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAppController.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAppPluginController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAppPluginController.java
new file mode 100644
index 0000000..d747d9e
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAppPluginController.java
@@ -0,0 +1,52 @@
+package dev.qingzhou.pushserver.controller;
+
+import dev.qingzhou.pushserver.common.PortalResponse;
+import dev.qingzhou.pushserver.common.PortalSessionSupport;
+import dev.qingzhou.pushserver.model.dto.portal.AppPluginConfigSaveRequest;
+import dev.qingzhou.pushserver.model.vo.portal.PortalAppPluginConfigVo;
+import dev.qingzhou.pushserver.service.PortalAppPluginService;
+import jakarta.servlet.http.HttpSession;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/v2/apps/{appId}/plugins")
+@RequiredArgsConstructor
+public class PortalAppPluginController {
+
+ private final PortalAppPluginService appPluginService;
+
+ @GetMapping
+ public PortalResponse> list(
+ @PathVariable Long appId,
+ HttpSession session
+ ) {
+ Long userId = PortalSessionSupport.requireUserId(session);
+ return PortalResponse.ok(appPluginService.listByApp(userId, appId));
+ }
+
+ @PostMapping
+ public PortalResponse saveConfig(
+ @PathVariable Long appId,
+ @Valid @RequestBody AppPluginConfigSaveRequest request,
+ HttpSession session
+ ) {
+ Long userId = PortalSessionSupport.requireUserId(session);
+ appPluginService.saveConfig(userId, appId, request);
+ return PortalResponse.ok(null);
+ }
+
+ @DeleteMapping("/{pluginKey}")
+ public PortalResponse deleteConfig(
+ @PathVariable Long appId,
+ @PathVariable String pluginKey,
+ HttpSession session
+ ) {
+ Long userId = PortalSessionSupport.requireUserId(session);
+ appPluginService.deleteConfig(userId, appId, pluginKey);
+ return PortalResponse.ok(null);
+ }
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalAuthController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAuthController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalAuthController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalAuthController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalCorpController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalCorpController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalCorpController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalCorpController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalErrorController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalErrorController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalErrorController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalErrorController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalInitController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalInitController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalInitController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalInitController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalMeController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalMeController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalMeController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalMeController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalMessageController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalMessageController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalMessageController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalMessageController.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalPluginController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalPluginController.java
new file mode 100644
index 0000000..ee13408
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalPluginController.java
@@ -0,0 +1,65 @@
+package dev.qingzhou.pushserver.controller;
+
+import dev.qingzhou.pushserver.common.PortalResponse;
+import dev.qingzhou.pushserver.model.dto.portal.PortalPluginCreateRequest;
+import dev.qingzhou.pushserver.model.vo.portal.PortalPluginVo;
+import dev.qingzhou.pushserver.service.PortalPluginService;
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v2/plugin")
+@RequiredArgsConstructor
+public class PortalPluginController {
+
+ private final PortalPluginService pluginService;
+
+ @PostMapping("/create")
+ public PortalResponse create(@Valid @RequestBody PortalPluginCreateRequest request) {
+ String token = pluginService.createPlugin(request);
+ return PortalResponse.ok(token);
+ }
+
+ @PostMapping("/reset-token")
+ public PortalResponse resetToken(@RequestBody Map payload) {
+ Integer id = payload.get("id");
+ if (id == null) {
+ return PortalResponse.fail("ID is required");
+ }
+ String newToken = pluginService.resetToken(id);
+ return PortalResponse.ok(newToken);
+ }
+
+ @PostMapping("/status")
+ public PortalResponse switchStatus(@RequestBody Map payload) {
+ Integer id = payload.get("id");
+ Integer status = payload.get("status");
+ if (id == null || status == null) {
+ return PortalResponse.fail("ID and status are required");
+ }
+ pluginService.switchStatus(id, status);
+ return PortalResponse.ok(null);
+ }
+
+ @GetMapping("/list")
+ public PortalResponse> list() {
+ return PortalResponse.ok(pluginService.listPlugins());
+ }
+
+ @PostMapping("/delete")
+ public PortalResponse delete(@RequestBody Map payload) {
+ Integer id = payload.get("id");
+ if (id == null) {
+ return PortalResponse.fail("ID is required");
+ }
+ pluginService.deletePlugin(id);
+ return PortalResponse.ok(null);
+ }
+}
\ No newline at end of file
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalPluginLogController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalPluginLogController.java
new file mode 100644
index 0000000..dc1ea36
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalPluginLogController.java
@@ -0,0 +1,70 @@
+package dev.qingzhou.pushserver.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import dev.qingzhou.pushserver.common.PortalResponse;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPluginActionLog;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPluginHeartbeatLog;
+import dev.qingzhou.pushserver.model.vo.portal.PortalPageResponse;
+import dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper;
+import dev.qingzhou.pushserver.mapper.portal.PortalPluginHeartbeatLogMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 插件观测日志(ActionAck / Heartbeat)查询
+ */
+@RestController
+@RequestMapping("/v2/plugins")
+@RequiredArgsConstructor
+public class PortalPluginLogController {
+
+ private final PortalPluginActionLogMapper actionLogMapper;
+ private final PortalPluginHeartbeatLogMapper heartbeatLogMapper;
+
+ @GetMapping("/{pluginKey}/actions")
+ public PortalResponse> listActions(
+ @PathVariable String pluginKey,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+
+ int safePage = Math.max(page, 1);
+ int safePageSize = Math.max(Math.min(pageSize, 200), 1);
+
+ Page p = actionLogMapper.selectPage(
+ Page.of(safePage, safePageSize),
+ new LambdaQueryWrapper()
+ .eq(PortalPluginActionLog::getPluginKey, pluginKey)
+ .orderByDesc(PortalPluginActionLog::getId)
+ );
+
+ PortalPageResponse resp = PortalPageResponse.of(
+ p.getRecords(), p.getTotal(), (int) p.getCurrent(), (int) p.getSize());
+ return PortalResponse.ok(resp);
+ }
+
+ @GetMapping("/{pluginKey}/heartbeats")
+ public PortalResponse> listHeartbeats(
+ @PathVariable String pluginKey,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+
+ int safePage = Math.max(page, 1);
+ int safePageSize = Math.max(Math.min(pageSize, 200), 1);
+
+ Page p = heartbeatLogMapper.selectPage(
+ Page.of(safePage, safePageSize),
+ new LambdaQueryWrapper()
+ .eq(PortalPluginHeartbeatLog::getPluginKey, pluginKey)
+ .orderByDesc(PortalPluginHeartbeatLog::getId)
+ );
+
+ PortalPageResponse resp = PortalPageResponse.of(
+ p.getRecords(), p.getTotal(), (int) p.getCurrent(), (int) p.getSize());
+ return PortalResponse.ok(resp);
+ }
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalProxyController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalProxyController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalProxyController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalProxyController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PortalSystemController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalSystemController.java
similarity index 53%
rename from src/main/java/dev/qingzhou/pushserver/controller/PortalSystemController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalSystemController.java
index 07daa58..f16cf51 100644
--- a/src/main/java/dev/qingzhou/pushserver/controller/PortalSystemController.java
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PortalSystemController.java
@@ -1,6 +1,7 @@
package dev.qingzhou.pushserver.controller;
import dev.qingzhou.pushserver.common.PortalResponse;
+import dev.qingzhou.pushserver.model.dto.portal.TurnstileConfigRequest;
import dev.qingzhou.pushserver.service.SystemConfigService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -38,4 +39,35 @@ public PortalResponse setIgnoreVersion(@RequestBody Map bo
}
return PortalResponse.ok(null);
}
+
+ @GetMapping("/turnstile")
+ public PortalResponse getTurnstileConfig() {
+ TurnstileConfigRequest config = new TurnstileConfigRequest();
+ config.setEnabled(systemConfigService.isTurnstileEnabled());
+ config.setSiteKey(systemConfigService.getTurnstileSiteKey());
+
+ String secretKey = systemConfigService.getTurnstileSecretKey();
+ if (secretKey != null && !secretKey.isBlank()) {
+ config.setSecretKey("******");
+ } else {
+ config.setSecretKey("");
+ }
+
+ return PortalResponse.ok(config);
+ }
+
+ @PutMapping("/turnstile")
+ public PortalResponse updateTurnstileConfig(@RequestBody TurnstileConfigRequest request) {
+ String secretKey = request.getSecretKey();
+ if ("******".equals(secretKey)) {
+ secretKey = systemConfigService.getTurnstileSecretKey();
+ }
+
+ systemConfigService.setTurnstileConfig(
+ request.isEnabled(),
+ request.getSiteKey(),
+ secretKey
+ );
+ return PortalResponse.ok(null);
+ }
}
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/PushController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PushController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/PushController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/PushController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/openapi/OpenApiMessageController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/openapi/OpenApiMessageController.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/controller/openapi/OpenApiMessageController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/openapi/OpenApiMessageController.java
diff --git a/src/main/java/dev/qingzhou/pushserver/controller/wecom/WecomCallbackController.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/wecom/WecomCallbackController.java
similarity index 58%
rename from src/main/java/dev/qingzhou/pushserver/controller/wecom/WecomCallbackController.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/controller/wecom/WecomCallbackController.java
index e20ec99..e2884cf 100644
--- a/src/main/java/dev/qingzhou/pushserver/controller/wecom/WecomCallbackController.java
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/controller/wecom/WecomCallbackController.java
@@ -1,16 +1,23 @@
package dev.qingzhou.pushserver.controller.wecom;
+import dev.qingzhou.push.api.model.ActionContext;
import dev.qingzhou.pushserver.manager.wecom.AesException;
import dev.qingzhou.pushserver.manager.wecom.WXBizMsgCrypt;
import dev.qingzhou.pushserver.manager.wecom.WecomMessageParser;
import dev.qingzhou.pushserver.manager.wecom.WecomMessagePayload;
import dev.qingzhou.pushserver.model.entity.portal.PortalCorpConfig;
import dev.qingzhou.pushserver.model.entity.portal.PortalWecomApp;
+import dev.qingzhou.pushserver.service.PluginManagerService;
import dev.qingzhou.pushserver.service.PortalCorpConfigService;
import dev.qingzhou.pushserver.service.PortalWecomAppService;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
+import java.util.UUID;
+
@Slf4j
@RestController
@RequestMapping("/v2/wecom/callback/{appId}")
@@ -18,14 +25,21 @@ public class WecomCallbackController {
private final PortalWecomAppService wecomAppService;
private final PortalCorpConfigService corpConfigService;
+ private final PluginManagerService pluginManagerService;
+ private final TaskExecutor taskExecutor;
- public WecomCallbackController(PortalWecomAppService wecomAppService, PortalCorpConfigService corpConfigService) {
+ public WecomCallbackController(PortalWecomAppService wecomAppService,
+ PortalCorpConfigService corpConfigService,
+ PluginManagerService pluginManagerService,
+ @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
this.wecomAppService = wecomAppService;
this.corpConfigService = corpConfigService;
+ this.pluginManagerService = pluginManagerService;
+ this.taskExecutor = taskExecutor;
}
/**
- * 企业微信回调 URL 验证 (GET)
+ * 企业微信回调 URL 校验 (GET)
*/
@GetMapping
public String verify(
@@ -34,7 +48,7 @@ public String verify(
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
-
+
log.info("Received WeCom callback verification for appId={}: signature={}, timestamp={}, nonce={}, echostr={}",
appId, signature, timestamp, nonce, echostr);
@@ -51,7 +65,7 @@ public String verify(
}
/**
- * 企业微信消息/事件推送 (POST)
+ * 企业微信消息/事件回调 (POST)
*/
@PostMapping
public String handleMessage(
@@ -67,38 +81,77 @@ public String handleMessage(
WXBizMsgCrypt wxcpt = getWxCrypt(appId);
String decryptedMsg = wxcpt.DecryptMsg(signature, timestamp, nonce, body);
log.info("Decrypted XML: {}", decryptedMsg);
-
+
WecomMessagePayload payload = WecomMessageParser.parse(decryptedMsg);
log.info("Parsed Payload: {}", payload);
-
- // TODO: 后续可以根据 payload.getMsgType() 或 payload.getEvent() 分发到不同的处理器
-
+
+ dispatchAsync(appId, payload);
+
return "success";
} catch (AesException e) {
log.error("WeCom message decryption failed", e);
- return "FAILED"; // 企业微信要求处理失败不返回 success,会重试
+ return "FAILED"; // 企业微信要求处理失败不要返回 success,会重试
} catch (Exception e) {
log.error("System error during message handling", e);
return "ERROR";
}
}
+ /**
+ * 异步分发到插件,避免阻塞企业微信回调响应。
+ */
+ private void dispatchAsync(Long appId, WecomMessagePayload payload) {
+ String type = "TEXT";
+ String content = payload.getContent();
+
+ if ("event".equalsIgnoreCase(payload.getReceiveMsgType())) {
+ type = "CLICK";
+ content = StringUtils.hasText(payload.getEventKey())
+ ? payload.getEventKey()
+ : payload.getEvent();
+ } else if ("image".equalsIgnoreCase(payload.getReceiveMsgType())) {
+ type = "IMAGE";
+ content = payload.getPicUrl();
+ }
+
+ if (!StringUtils.hasText(content) && StringUtils.hasText(payload.getPicUrl())) {
+ content = payload.getPicUrl();
+ }
+
+ ActionContext ctx = ActionContext.builder()
+ .eventId(payload.getMsgId() != null ? String.valueOf(payload.getMsgId()) : UUID.randomUUID().toString())
+ .appId(String.valueOf(appId))
+ .userId(payload.getFromUserName())
+ .userName(null)
+ .type(type)
+ .content(content)
+ .pluginConfig(null)
+ .build();
+
+ taskExecutor.execute(() -> {
+ try {
+ pluginManagerService.dispatch(ctx);
+ } catch (Exception ex) {
+ log.error("Dispatch to plugins failed for appId={} eventId={}", appId, ctx.getEventId(), ex);
+ }
+ });
+ }
+
private WXBizMsgCrypt getWxCrypt(Long appId) throws AesException {
PortalWecomApp app = wecomAppService.getById(appId);
if (app == null) {
throw new AesException(AesException.IllegalAesKey, "App not found");
}
-
- // 校验配置是否完整
+
if (app.getToken() == null || app.getEncodingAesKey() == null) {
- throw new AesException(AesException.IllegalAesKey, "Token or EncodingAESKey not configured for this app");
+ throw new AesException(AesException.IllegalAesKey, "Token or EncodingAESKey not configured for this app");
}
PortalCorpConfig corpConfig = corpConfigService.getByUserId(app.getUserId());
if (corpConfig == null || corpConfig.getCorpId() == null) {
- throw new AesException(AesException.ValidateCorpidError, "CorpConfig not found");
+ throw new AesException(AesException.ValidateCorpidError, "CorpConfig not found");
}
return new WXBizMsgCrypt(app.getToken(), app.getEncodingAesKey(), corpConfig.getCorpId());
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/exception/GlobalExceptionHandler.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/exception/GlobalExceptionHandler.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/exception/GlobalExceptionHandler.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/exception/GlobalExceptionHandler.java
diff --git a/src/main/java/dev/qingzhou/pushserver/exception/PortalException.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/exception/PortalException.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/exception/PortalException.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/exception/PortalException.java
diff --git a/src/main/java/dev/qingzhou/pushserver/exception/PortalExceptionHandler.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/exception/PortalExceptionHandler.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/exception/PortalExceptionHandler.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/exception/PortalExceptionHandler.java
diff --git a/src/main/java/dev/qingzhou/pushserver/exception/PortalStatus.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/exception/PortalStatus.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/exception/PortalStatus.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/exception/PortalStatus.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/GrpcAdapter.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/GrpcAdapter.java
new file mode 100644
index 0000000..5a4c680
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/GrpcAdapter.java
@@ -0,0 +1,54 @@
+package dev.qingzhou.pushserver.grpc;
+
+import dev.qingzhou.push.api.grpc.*;
+import dev.qingzhou.pushserver.model.dto.openapi.PushRequest;
+import org.springframework.stereotype.Component;
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+public class GrpcAdapter {
+
+ public PushRequest toCorePushRequest(dev.qingzhou.push.api.grpc.PushRequest protoReq) {
+ PushRequest coreReq = new PushRequest();
+ coreReq.setTarget(protoReq.getTargetUserId());
+ coreReq.setContent(protoReq.getContent());
+ coreReq.setTitle(protoReq.getTitle());
+ coreReq.setUrl(protoReq.getUrl());
+ coreReq.setMediaId(protoReq.getMediaId());
+
+ switch (protoReq.getType()) {
+ case PUSH_CONTENT_TYPE_MARKDOWN:
+ coreReq.setType("markdown");
+ break;
+ case PUSH_CONTENT_TYPE_IMAGE:
+ coreReq.setType("image");
+ break;
+ case PUSH_CONTENT_TYPE_NEWS:
+ coreReq.setType("news");
+ break;
+ case PUSH_CONTENT_TYPE_TEXT_CARD:
+ coreReq.setType("textcard");
+ break;
+ case PUSH_CONTENT_TYPE_TEXT:
+ default:
+ coreReq.setType("text");
+ break;
+ }
+
+ if (protoReq.getArticlesCount() > 0) {
+ List articles = new ArrayList<>();
+ for (PushArticle protoArticle : protoReq.getArticlesList()) {
+ PushRequest.Article article = new PushRequest.Article();
+ article.setTitle(protoArticle.getTitle());
+ article.setDescription(protoArticle.getDescription());
+ article.setUrl(protoArticle.getUrl());
+ article.setPicUrl(protoArticle.getPicUrl());
+ articles.add(article);
+ }
+ coreReq.setArticles(articles);
+ }
+
+ return coreReq;
+ }
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginAuthInterceptor.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginAuthInterceptor.java
new file mode 100644
index 0000000..38e4fa6
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginAuthInterceptor.java
@@ -0,0 +1,45 @@
+package dev.qingzhou.pushserver.grpc;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import dev.qingzhou.pushserver.mapper.portal.PortalPluginMapper;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPlugin;
+import io.grpc.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PluginAuthInterceptor implements ServerInterceptor {
+
+ public static final Context.Key PLUGIN_CONTEXT_KEY = Context.key("plugin");
+
+ private final PortalPluginMapper pluginMapper;
+
+ @Override
+ public ServerCall.Listener interceptCall(
+ ServerCall call, Metadata headers, ServerCallHandler next) {
+
+ String authHeader = headers.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER));
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid Authorization header"), headers);
+ return new ServerCall.Listener<>() {};
+ }
+
+ String token = authHeader.substring(7);
+
+ // TODO: Add Caching here to avoid DB hit on every connect
+ PortalPlugin plugin = pluginMapper.selectOne(new LambdaQueryWrapper()
+ .eq(PortalPlugin::getToken, token)
+ .eq(PortalPlugin::getStatus, 1)); // Must be enabled
+
+ if (plugin == null) {
+ call.close(Status.UNAUTHENTICATED.withDescription("Invalid Token"), headers);
+ return new ServerCall.Listener<>() {};
+ }
+
+ Context ctx = Context.current().withValue(PLUGIN_CONTEXT_KEY, plugin);
+ return Contexts.interceptCall(ctx, call, headers, next);
+ }
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginConnectionManager.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginConnectionManager.java
new file mode 100644
index 0000000..e9dbfc2
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginConnectionManager.java
@@ -0,0 +1,57 @@
+package dev.qingzhou.pushserver.grpc;
+
+import dev.qingzhou.push.api.grpc.PlatformDownstreamPacket;
+import io.grpc.stub.StreamObserver;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class PluginConnectionManager {
+
+ // Key: Plugin Key, Value: Response Observer (to send data to plugin)
+ private final Map> connections = new ConcurrentHashMap<>();
+ // Key: Plugin Key, Value: Last heartbeat timestamp (ms)
+ private final Map lastHeartbeats = new ConcurrentHashMap<>();
+
+ public void register(String pluginKey, StreamObserver observer) {
+ log.info("Plugin connected: {}", pluginKey);
+ connections.put(pluginKey, observer);
+ lastHeartbeats.put(pluginKey, System.currentTimeMillis());
+ }
+
+ public void unregister(String pluginKey) {
+ if (pluginKey != null) {
+ log.info("Plugin disconnected: {}", pluginKey);
+ connections.remove(pluginKey);
+ lastHeartbeats.remove(pluginKey);
+ }
+ }
+
+ public StreamObserver get(String pluginKey) {
+ return connections.get(pluginKey);
+ }
+
+ public boolean isConnected(String pluginKey) {
+ return connections.containsKey(pluginKey);
+ }
+
+ public void updateHeartbeat(String pluginKey) {
+ if (pluginKey != null) {
+ lastHeartbeats.put(pluginKey, System.currentTimeMillis());
+ }
+ }
+
+ public Long getLastHeartbeat(String pluginKey) {
+ return lastHeartbeats.get(pluginKey);
+ }
+
+ /**
+ * Snapshot of last heartbeat timestamps for monitoring.
+ */
+ public Map snapshotHeartbeats() {
+ return new ConcurrentHashMap<>(lastHeartbeats);
+ }
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginGatewayImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginGatewayImpl.java
new file mode 100644
index 0000000..58f7d81
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginGatewayImpl.java
@@ -0,0 +1,180 @@
+package dev.qingzhou.pushserver.grpc;
+
+import dev.qingzhou.push.api.grpc.*;
+import dev.qingzhou.push.api.grpc.PluginGatewayGrpc.PluginGatewayImplBase;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPlugin;
+import io.grpc.stub.StreamObserver;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import dev.qingzhou.pushserver.service.PushService;
+import dev.qingzhou.pushserver.service.PluginManagerService;
+import dev.qingzhou.push.api.model.PluginMeta;
+import dev.qingzhou.push.api.model.ConfigField;
+import dev.qingzhou.push.api.model.ConfigType;
+import dev.qingzhou.push.api.model.SelectOption;
+import dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper;
+import dev.qingzhou.pushserver.mapper.portal.PortalPluginHeartbeatLogMapper;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPluginActionLog;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPluginHeartbeatLog;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PluginGatewayImpl extends PluginGatewayImplBase {
+
+ private final PluginConnectionManager connectionManager;
+ private final GrpcAdapter grpcAdapter;
+ private final PushService pushService;
+ private final PluginManagerService pluginManagerService;
+ private final PortalPluginActionLogMapper actionLogMapper;
+ private final PortalPluginHeartbeatLogMapper heartbeatLogMapper;
+
+ @Override
+ public StreamObserver connect(StreamObserver responseObserver) {
+
+ PortalPlugin plugin = PluginAuthInterceptor.PLUGIN_CONTEXT_KEY.get();
+ if (plugin == null) {
+ // Should be caught by interceptor, but safety check
+ responseObserver.onError(io.grpc.Status.UNAUTHENTICATED.asRuntimeException());
+ return new StreamObserver<>() {
+ @Override public void onNext(PluginUpstreamPacket value) {}
+ @Override public void onError(Throwable t) {}
+ @Override public void onCompleted() {}
+ };
+ }
+
+ final String pluginKey = plugin.getPluginKey();
+ log.info("New connection stream from plugin: {}", pluginKey);
+
+ return new StreamObserver() {
+ @Override
+ public void onNext(PluginUpstreamPacket packet) {
+ try {
+ handlePacket(pluginKey, packet, responseObserver);
+ } catch (Exception e) {
+ log.error("Error handling packet from {}", pluginKey, e);
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ log.warn("Stream error for plugin {}: {}", pluginKey, t.getMessage());
+ cleanup();
+ }
+
+ @Override
+ public void onCompleted() {
+ log.info("Stream completed for plugin {}", pluginKey);
+ cleanup();
+ responseObserver.onCompleted();
+ }
+
+ private void cleanup() {
+ connectionManager.unregister(pluginKey);
+ pluginManagerService.unregisterRemotePlugin(pluginKey);
+ }
+ };
+ }
+
+ private void handlePacket(String pluginKey, PluginUpstreamPacket packet, StreamObserver responseObserver) {
+ if (packet.hasRegister()) {
+ // Register logic
+ RegisterRequest req = packet.getRegister();
+ if (!req.getPluginKey().equals(pluginKey)) {
+ log.warn("Plugin Key mismatch in register packet. Token says {}, packet says {}", pluginKey, req.getPluginKey());
+ }
+
+ // Convert RegisterRequest to PluginMeta
+ PluginMeta meta = PluginMeta.builder()
+ .id(pluginKey)
+ .version(req.getPluginVersion())
+ .maxConcurrency(req.getMaxConcurrency())
+ .build();
+
+ // Convert ConfigDefinitions if needed... (simplified here)
+
+ connectionManager.register(pluginKey, responseObserver);
+ pluginManagerService.registerRemotePlugin(pluginKey, meta);
+
+ responseObserver.onNext(PlatformDownstreamPacket.newBuilder()
+ .setHeader(PacketHeader.newBuilder()
+ .setTraceId(packet.getHeader().getTraceId())
+ .setTimestamp(System.currentTimeMillis())
+ .build())
+ .setRegisterAck(RegisterResponse.newBuilder().setSuccess(true).build())
+ .build());
+
+ } else if (packet.hasHeartbeat()) {
+ Heartbeat hb = packet.getHeartbeat();
+ connectionManager.updateHeartbeat(pluginKey);
+ log.debug("Heartbeat from {} inflight={} uptime={}", pluginKey, hb.getCurrentInflight(), hb.getUptimeSeconds());
+ saveHeartbeat(pluginKey, hb);
+ // Currently protocol has no downstream heartbeat ack; tracking timestamp is enough.
+ } else if (packet.hasActionAck()) {
+ ActionAck ack = packet.getActionAck();
+ log.info("ActionAck from {} eventId={} status={} msg={}",
+ pluginKey, ack.getEventId(), ack.getStatus(), ack.getMessage());
+ saveActionAck(pluginKey, ack);
+ } else if (packet.hasPushRequest()) {
+ dev.qingzhou.pushserver.model.dto.openapi.PushRequest coreReq = grpcAdapter.toCorePushRequest(packet.getPushRequest());
+
+ try {
+ pushService.push(coreReq);
+
+ responseObserver.onNext(PlatformDownstreamPacket.newBuilder()
+ .setHeader(PacketHeader.newBuilder()
+ .setTraceId(packet.getHeader().getTraceId())
+ .setTimestamp(System.currentTimeMillis())
+ .build())
+ .setPushResponse(PushResponse.newBuilder().setSuccess(true).build())
+ .build());
+ } catch (Exception e) {
+ log.error("Push failed for plugin {}", pluginKey, e);
+ responseObserver.onNext(PlatformDownstreamPacket.newBuilder()
+ .setHeader(PacketHeader.newBuilder()
+ .setTraceId(packet.getHeader().getTraceId())
+ .setTimestamp(System.currentTimeMillis())
+ .build())
+ .setPushResponse(PushResponse.newBuilder()
+ .setSuccess(false)
+ .setErrorMsg(e.getMessage())
+ .build())
+ .build());
+ }
+ }
+ }
+
+ private void saveActionAck(String pluginKey, ActionAck ack) {
+ PortalPluginActionLog logEntity = new PortalPluginActionLog();
+ logEntity.setPluginKey(pluginKey);
+ logEntity.setEventId(ack.getEventId());
+ logEntity.setStatus(ack.getStatusValue());
+ logEntity.setMessage(ack.getMessage());
+ logEntity.setCreatedAt(System.currentTimeMillis());
+ try {
+ int updated = actionLogMapper.update(logEntity,
+ new LambdaQueryWrapper()
+ .eq(PortalPluginActionLog::getPluginKey, pluginKey)
+ .eq(PortalPluginActionLog::getEventId, ack.getEventId()));
+ if (updated == 0) {
+ actionLogMapper.insert(logEntity);
+ }
+ } catch (Exception e) {
+ log.warn("Failed to persist ActionAck for plugin {} event {}", pluginKey, ack.getEventId(), e);
+ }
+ }
+
+ private void saveHeartbeat(String pluginKey, Heartbeat hb) {
+ PortalPluginHeartbeatLog logEntity = new PortalPluginHeartbeatLog();
+ logEntity.setPluginKey(pluginKey);
+ logEntity.setCurrentInflight(hb.getCurrentInflight());
+ logEntity.setUptimeSeconds((int) hb.getUptimeSeconds());
+ logEntity.setCreatedAt(System.currentTimeMillis());
+ heartbeatLogMapper.insert(logEntity);
+ }
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginPacketDispatcher.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginPacketDispatcher.java
new file mode 100644
index 0000000..9f2044a
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/grpc/PluginPacketDispatcher.java
@@ -0,0 +1,45 @@
+package dev.qingzhou.pushserver.grpc;
+
+import dev.qingzhou.push.api.grpc.PacketHeader;
+import dev.qingzhou.push.api.grpc.PlatformDownstreamPacket;
+import dev.qingzhou.push.api.grpc.UserActionEvent;
+import io.grpc.stub.StreamObserver;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PluginPacketDispatcher {
+
+ private final PluginConnectionManager connectionManager;
+
+ public boolean sendUserAction(String pluginKey, UserActionEvent actionEvent) {
+ StreamObserver observer = connectionManager.get(pluginKey);
+ if (observer == null) {
+ log.warn("Cannot send action to plugin {}: No active connection", pluginKey);
+ return false;
+ }
+
+ PlatformDownstreamPacket packet = PlatformDownstreamPacket.newBuilder()
+ .setHeader(PacketHeader.newBuilder()
+ .setTimestamp(System.currentTimeMillis())
+ .setTraceId(UUID.randomUUID().toString())
+ .setPluginKey(pluginKey)
+ .build())
+ .setActionEvent(actionEvent)
+ .build();
+
+ try {
+ observer.onNext(packet);
+ return true;
+ } catch (Exception e) {
+ log.error("Failed to send packet to plugin {}", pluginKey, e);
+ connectionManager.unregister(pluginKey);
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/AesException.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/AesException.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/AesException.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/AesException.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WXBizMsgCrypt.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WXBizMsgCrypt.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WXBizMsgCrypt.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WXBizMsgCrypt.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomAgentInfo.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomAgentInfo.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomAgentInfo.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomAgentInfo.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomApiClient.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomApiClient.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomApiClient.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomApiClient.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessageParser.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessageParser.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessageParser.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessageParser.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessagePayload.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessagePayload.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessagePayload.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomMessagePayload.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomSendResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomSendResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomSendResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomSendResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomToken.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomToken.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomToken.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/manager/wecom/WecomToken.java
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppApiKeyMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppApiKeyMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppApiKeyMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppApiKeyMapper.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppPluginConfigMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppPluginConfigMapper.java
new file mode 100644
index 0000000..edb6d07
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalAppPluginConfigMapper.java
@@ -0,0 +1,9 @@
+package dev.qingzhou.pushserver.mapper.portal;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import dev.qingzhou.pushserver.model.entity.portal.PortalAppPluginConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PortalAppPluginConfigMapper extends BaseMapper {
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalCorpConfigMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalCorpConfigMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalCorpConfigMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalCorpConfigMapper.java
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalMessageLogMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalMessageLogMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalMessageLogMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalMessageLogMapper.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginActionLogMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginActionLogMapper.java
new file mode 100644
index 0000000..25a657c
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginActionLogMapper.java
@@ -0,0 +1,9 @@
+package dev.qingzhou.pushserver.mapper.portal;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPluginActionLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PortalPluginActionLogMapper extends BaseMapper {
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginHeartbeatLogMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginHeartbeatLogMapper.java
new file mode 100644
index 0000000..f0c9f16
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginHeartbeatLogMapper.java
@@ -0,0 +1,9 @@
+package dev.qingzhou.pushserver.mapper.portal;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPluginHeartbeatLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PortalPluginHeartbeatLogMapper extends BaseMapper {
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginMapper.java
new file mode 100644
index 0000000..faaeff2
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalPluginMapper.java
@@ -0,0 +1,9 @@
+package dev.qingzhou.pushserver.mapper.portal;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import dev.qingzhou.pushserver.model.entity.portal.PortalPlugin;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface PortalPluginMapper extends BaseMapper {
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalProxyConfigMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalProxyConfigMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalProxyConfigMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalProxyConfigMapper.java
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalSystemConfigMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalSystemConfigMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalSystemConfigMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalSystemConfigMapper.java
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalUserMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalUserMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalUserMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalUserMapper.java
diff --git a/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalWecomAppMapper.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalWecomAppMapper.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalWecomAppMapper.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/mapper/portal/PortalWecomAppMapper.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/openapi/OpenApiMessageSendRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/openapi/OpenApiMessageSendRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/openapi/OpenApiMessageSendRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/openapi/OpenApiMessageSendRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/openapi/PushRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/openapi/PushRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/openapi/PushRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/openapi/PushRequest.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/AppPluginConfigSaveRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/AppPluginConfigSaveRequest.java
new file mode 100644
index 0000000..d49ba4d
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/AppPluginConfigSaveRequest.java
@@ -0,0 +1,15 @@
+package dev.qingzhou.pushserver.model.dto.portal;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class AppPluginConfigSaveRequest {
+
+ @NotNull(message = "Plugin key is required")
+ private String pluginKey;
+
+ private String configJson;
+
+ private Integer status;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppApiKeyUpdateRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppApiKeyUpdateRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppApiKeyUpdateRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppApiKeyUpdateRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppCreateRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppCreateRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppCreateRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppCreateRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppUpdateRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppUpdateRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppUpdateRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalAppUpdateRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalCorpConfigRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalCorpConfigRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalCorpConfigRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalCorpConfigRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalInitRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalInitRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalInitRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalInitRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalLoginRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalLoginRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalLoginRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalLoginRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageSendRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageSendRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageSendRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageSendRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageType.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageType.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageType.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalMessageType.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPasswordUpdateRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPasswordUpdateRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPasswordUpdateRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPasswordUpdateRequest.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPluginCreateRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPluginCreateRequest.java
new file mode 100644
index 0000000..0c9bcd0
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalPluginCreateRequest.java
@@ -0,0 +1,18 @@
+package dev.qingzhou.pushserver.model.dto.portal;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+@Data
+public class PortalPluginCreateRequest {
+
+ @NotBlank(message = "Plugin Key cannot be empty")
+ @Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "Plugin Key can only contain letters, numbers, underscores and hyphens")
+ private String pluginKey;
+
+ @NotBlank(message = "Name cannot be empty")
+ private String name;
+
+ private String description;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalProxyConfigRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalProxyConfigRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalProxyConfigRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalProxyConfigRequest.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalRegisterRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalRegisterRequest.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalRegisterRequest.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/PortalRegisterRequest.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/TurnstileConfigRequest.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/TurnstileConfigRequest.java
new file mode 100644
index 0000000..165bcba
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/dto/portal/TurnstileConfigRequest.java
@@ -0,0 +1,10 @@
+package dev.qingzhou.pushserver.model.dto.portal;
+
+import lombok.Data;
+
+@Data
+public class TurnstileConfigRequest {
+ private boolean enabled;
+ private String siteKey;
+ private String secretKey;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppApiKey.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppApiKey.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppApiKey.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppApiKey.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppPluginConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppPluginConfig.java
new file mode 100644
index 0000000..f52b956
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalAppPluginConfig.java
@@ -0,0 +1,38 @@
+package dev.qingzhou.pushserver.model.entity.portal;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("v2_app_plugin_config")
+public class PortalAppPluginConfig {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("app_id")
+ private Long appId;
+
+ @TableField("plugin_key")
+ private String pluginKey;
+
+ /**
+ * JSON string of configuration
+ */
+ @TableField("config_json")
+ private String configJson;
+
+ /**
+ * 1: Enabled, 0: Disabled
+ */
+ private Integer status;
+
+ @TableField("created_at")
+ private Long createdAt;
+
+ @TableField("updated_at")
+ private Long updatedAt;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalCorpConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalCorpConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalCorpConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalCorpConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalMessageLog.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalMessageLog.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalMessageLog.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalMessageLog.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPlugin.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPlugin.java
new file mode 100644
index 0000000..9af0124
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPlugin.java
@@ -0,0 +1,26 @@
+package dev.qingzhou.pushserver.model.entity.portal;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("v2_plugin")
+public class PortalPlugin {
+ @TableId(type = IdType.AUTO)
+ private Integer id;
+
+ private String pluginKey;
+ private String name;
+ private String description;
+ private String token;
+
+ /**
+ * 1: Enabled, 0: Disabled
+ */
+ private Integer status;
+
+ private Long createdAt;
+ private Long updatedAt;
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPluginActionLog.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPluginActionLog.java
new file mode 100644
index 0000000..3627f5c
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPluginActionLog.java
@@ -0,0 +1,24 @@
+package dev.qingzhou.pushserver.model.entity.portal;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("v2_plugin_action_log")
+public class PortalPluginActionLog {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private String pluginKey;
+ private String eventId;
+ private Integer status;
+ private String message;
+ private String appId;
+ private String appName;
+ private String userId;
+ private String type;
+ private String content;
+ private String pluginConfig;
+ private Long createdAt;
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPluginHeartbeatLog.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPluginHeartbeatLog.java
new file mode 100644
index 0000000..9696274
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalPluginHeartbeatLog.java
@@ -0,0 +1,17 @@
+package dev.qingzhou.pushserver.model.entity.portal;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("v2_plugin_heartbeat_log")
+public class PortalPluginHeartbeatLog {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private String pluginKey;
+ private Integer currentInflight;
+ private Integer uptimeSeconds;
+ private Long createdAt;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalProxyConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalProxyConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalProxyConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalProxyConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalSystemConfig.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalSystemConfig.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalSystemConfig.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalSystemConfig.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalUser.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalUser.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalUser.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalUser.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalWecomApp.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalWecomApp.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalWecomApp.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/entity/portal/PortalWecomApp.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardChartsResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardChartsResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardChartsResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardChartsResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardLogResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardLogResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardLogResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardLogResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardStatsResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardStatsResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardStatsResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/DashboardStatsResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppApiKeyResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppApiKeyResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppApiKeyResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppApiKeyResponse.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppPluginConfigVo.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppPluginConfigVo.java
new file mode 100644
index 0000000..b6fd624
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppPluginConfigVo.java
@@ -0,0 +1,25 @@
+package dev.qingzhou.pushserver.model.vo.portal;
+
+import dev.qingzhou.push.api.model.PluginMeta;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class PortalAppPluginConfigVo {
+ // 插件的基本信息
+ private String pluginKey;
+ private String name;
+ private String description;
+
+ // 插件定义的配置字段
+ private PluginMeta meta;
+
+ // 当前应用配置的值
+ private String configJson;
+
+ // 在该应用中是否启用
+ private Integer status; // 1: Enabled, 0: Disabled
+
+ private Long updatedAt;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalAppResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalCorpResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalCorpResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalCorpResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalCorpResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogConverter.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogConverter.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogConverter.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogConverter.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalMessageLogResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPageResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPageResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPageResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPageResponse.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPluginVo.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPluginVo.java
new file mode 100644
index 0000000..1e86d35
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalPluginVo.java
@@ -0,0 +1,18 @@
+package dev.qingzhou.pushserver.model.vo.portal;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class PortalPluginVo {
+ private Integer id;
+ private String pluginKey;
+ private String name;
+ private String description;
+ private Integer status; // 1: Enabled, 0: Disabled
+ private Long createdAt;
+ // 注意:Token 通常不在此处返回,或者只返回脱敏后的版本
+ private Boolean isConnected; // 预留字段:当前是否在线
+ private Boolean isBuiltin;
+}
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalProxyConfigResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalProxyConfigResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalProxyConfigResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalProxyConfigResponse.java
diff --git a/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalUserResponse.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalUserResponse.java
similarity index 100%
rename from src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalUserResponse.java
rename to push-server-core/src/main/java/dev/qingzhou/pushserver/model/vo/portal/PortalUserResponse.java
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/monitor/PluginHeartbeatMonitor.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/monitor/PluginHeartbeatMonitor.java
new file mode 100644
index 0000000..1d63abd
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/monitor/PluginHeartbeatMonitor.java
@@ -0,0 +1,41 @@
+package dev.qingzhou.pushserver.monitor;
+
+import dev.qingzhou.pushserver.grpc.PluginConnectionManager;
+import dev.qingzhou.pushserver.service.PluginManagerService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 定期检查远程插件心跳;超时则强制下线。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PluginHeartbeatMonitor {
+
+ private static final long TIMEOUT_MS = 90_000; // 90 秒无心跳视为离线
+
+ private final PluginConnectionManager connectionManager;
+ private final PluginManagerService pluginManagerService;
+
+ @Scheduled(fixedDelay = 30_000)
+ public void checkHeartbeats() {
+ long now = System.currentTimeMillis();
+ for (Map.Entry entry : connectionManager.snapshotHeartbeats().entrySet()) {
+ String pluginKey = entry.getKey();
+ Long last = entry.getValue();
+ if (last == null) {
+ continue;
+ }
+ if (now - last > TIMEOUT_MS) {
+ log.warn("Plugin {} heartbeat timeout ({} ms > {}) - forcing disconnect", pluginKey, now - last, TIMEOUT_MS);
+ connectionManager.unregister(pluginKey);
+ pluginManagerService.unregisterRemotePlugin(pluginKey);
+ }
+ }
+ }
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/plugin/GrpcPluginProxy.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/plugin/GrpcPluginProxy.java
new file mode 100644
index 0000000..e9bf903
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/plugin/GrpcPluginProxy.java
@@ -0,0 +1,60 @@
+package dev.qingzhou.pushserver.plugin;
+
+import dev.qingzhou.push.api.model.ActionContext;
+import dev.qingzhou.push.api.model.PluginMeta;
+import dev.qingzhou.push.api.spi.PushPlugin;
+import dev.qingzhou.push.api.spi.PushSender;
+import dev.qingzhou.pushserver.grpc.PluginPacketDispatcher;
+import dev.qingzhou.push.api.grpc.UserActionEvent;
+import dev.qingzhou.push.api.grpc.UserActionType;
+import lombok.RequiredArgsConstructor;
+
+import java.util.UUID;
+
+@RequiredArgsConstructor
+public class GrpcPluginProxy implements PushPlugin {
+
+ private final String pluginKey;
+ private final PluginMeta meta;
+ private final PluginPacketDispatcher dispatcher;
+
+ @Override
+ public PluginMeta getMeta() {
+ return meta;
+ }
+
+ @Override
+ public boolean supports(ActionContext context) {
+ // 简单路由:真正的逻辑应在 Manager 层根据 commands 前缀匹配
+ return true;
+ }
+
+ @Override
+ public void init(PushSender sender) {
+ // Remote plugins don't need local sender injection
+ }
+
+ @Override
+ public void handle(ActionContext context) {
+ UserActionEvent.Builder eventBuilder = UserActionEvent.newBuilder()
+ .setEventId(context.getEventId() == null ? UUID.randomUUID().toString() : context.getEventId())
+ .setAppId(context.getAppId() == null ? "" : context.getAppId())
+ .setUserId(context.getUserId() == null ? "" : context.getUserId())
+ .setUserName(context.getUserName() == null ? "" : context.getUserName())
+ .setContent(context.getContent() == null ? "" : context.getContent());
+
+ if ("CLICK".equalsIgnoreCase(context.getType())) {
+ eventBuilder.setType(UserActionType.USER_ACTION_TYPE_CLICK);
+ } else if ("IMAGE".equalsIgnoreCase(context.getType())) {
+ eventBuilder.setType(UserActionType.USER_ACTION_TYPE_IMAGE);
+ } else {
+ eventBuilder.setType(UserActionType.USER_ACTION_TYPE_TEXT);
+ }
+
+ if (context.getPluginConfig() != null) {
+ eventBuilder.putAllPluginConfig(context.getPluginConfig());
+ }
+
+ dispatcher.sendUserAction(pluginKey, eventBuilder.build());
+ }
+}
diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/plugin/builtin/WebhookPlugin.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/plugin/builtin/WebhookPlugin.java
new file mode 100644
index 0000000..14a870b
--- /dev/null
+++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/plugin/builtin/WebhookPlugin.java
@@ -0,0 +1,114 @@
+package dev.qingzhou.pushserver.plugin.builtin;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.qingzhou.push.api.model.ActionContext;
+import dev.qingzhou.push.api.model.ConfigField;
+import dev.qingzhou.push.api.model.ConfigType;
+import dev.qingzhou.push.api.model.PluginMeta;
+import dev.qingzhou.push.api.spi.PushPlugin;
+import dev.qingzhou.push.api.spi.PushSender;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WebhookPlugin implements PushPlugin {
+
+ private final ObjectMapper objectMapper;
+ private final RestTemplate restTemplate = new RestTemplate();
+
+ @Override
+ public PluginMeta getMeta() {
+ return PluginMeta.builder()
+ .id("builtin-webhook")
+ .name("Webhook")
+ .description("将消息事件以 JSON 格式推送到指定的 HTTP URL")
+ .version("1.0.0")
+ .maxConcurrency(100)
+ .configFields(List.of(
+ ConfigField.builder()
+ .name("url")
+ .label("Webhook URL")
+ .type(ConfigType.TEXT)
+ .required(true)
+ .description("接收事件的接口地址")
+ .build(),
+ ConfigField.builder()
+ .name("headerName")
+ .label("Auth Header Name")
+ .type(ConfigType.TEXT)
+ .required(false)
+ .description("可选,例如 Authorization")
+ .build(),
+ ConfigField.builder()
+ .name("headerValue")
+ .label("Auth Header Value")
+ .type(ConfigType.PASSWORD)
+ .required(false)
+ .description("可选,例如 Bearer xxx")
+ .build()
+ ))
+ .build();
+ }
+
+ @Override
+ public boolean supports(ActionContext context) {
+ // 只有当运行时配置中包含了 'url' 时,才由本插件处理
+ return context.getConfig("url") != null && !context.getConfig("url").isBlank();
+ }
+
+ @Override
+ public void init(PushSender sender) {
+ // Webhook 通常是单向通知,不需要回复用户,所以这里不需要保存 sender
+ }
+
+ @Override
+ public void handle(ActionContext context) {
+ String url = context.getConfig("url");
+ String headerName = context.getConfig("headerName");
+ String headerValue = context.getConfig("headerValue");
+
+ // 异步执行,避免阻塞主事件循环
+ CompletableFuture.runAsync(() -> {
+ try {
+ Map payload = new HashMap<>();
+ payload.put("eventId", context.getEventId());
+ payload.put("type", context.getType());
+ payload.put("userId", context.getUserId());
+ payload.put("content", context.getContent());
+ payload.put("timestamp", System.currentTimeMillis());
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ if (StringUtils.hasText(headerName) && StringUtils.hasText(headerValue)) {
+ headers.set(headerName, headerValue);
+ }
+
+ log.debug("Sending webhook to {}", url);
+ HttpEntity