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> entity = new HttpEntity<>(payload, headers); + String response = restTemplate.postForObject(url, entity, String.class); + log.debug("Webhook response: {}", response); + + } catch (Exception e) { + log.error("Failed to send webhook to {}: {}", url, e.getMessage()); + } + }); + } + + @Override + public void shutdown() { + // No resources to release + } +} diff --git a/src/main/java/dev/qingzhou/pushserver/security/CaptchaService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/security/CaptchaService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/security/CaptchaService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/security/CaptchaService.java diff --git a/src/main/java/dev/qingzhou/pushserver/security/PortalAppApiKeyRateLimiter.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalAppApiKeyRateLimiter.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/security/PortalAppApiKeyRateLimiter.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalAppApiKeyRateLimiter.java diff --git a/src/main/java/dev/qingzhou/pushserver/security/PortalJsonLoginAuthenticationFilter.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalJsonLoginAuthenticationFilter.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/security/PortalJsonLoginAuthenticationFilter.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalJsonLoginAuthenticationFilter.java diff --git a/src/main/java/dev/qingzhou/pushserver/security/PortalUserDetails.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalUserDetails.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/security/PortalUserDetails.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalUserDetails.java diff --git a/src/main/java/dev/qingzhou/pushserver/security/PortalUserDetailsService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalUserDetailsService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/security/PortalUserDetailsService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/security/PortalUserDetailsService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/DashboardService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/DashboardService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/DashboardService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/DashboardService.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PluginManagerService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PluginManagerService.java new file mode 100644 index 0000000..c7b4115 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PluginManagerService.java @@ -0,0 +1,19 @@ +package dev.qingzhou.pushserver.service; + +import dev.qingzhou.push.api.model.ActionContext; +import dev.qingzhou.push.api.model.PluginMeta; +import dev.qingzhou.push.api.spi.PushPlugin; +import java.util.List; + +public interface PluginManagerService { + + void registerRemotePlugin(String pluginKey, PluginMeta meta); + + void unregisterRemotePlugin(String pluginKey); + + List getAllPlugins(); + + List getLocalPlugins(); + + void dispatch(ActionContext context); +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalAccessTokenService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAccessTokenService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalAccessTokenService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAccessTokenService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalAppApiKeyService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAppApiKeyService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalAppApiKeyService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAppApiKeyService.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAppPluginService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAppPluginService.java new file mode 100644 index 0000000..9a6e939 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalAppPluginService.java @@ -0,0 +1,14 @@ +package dev.qingzhou.pushserver.service; + +import dev.qingzhou.pushserver.model.dto.portal.AppPluginConfigSaveRequest; +import dev.qingzhou.pushserver.model.vo.portal.PortalAppPluginConfigVo; +import java.util.List; + +public interface PortalAppPluginService { + + List listByApp(Long userId, Long appId); + + void saveConfig(Long userId, Long appId, AppPluginConfigSaveRequest request); + + void deleteConfig(Long userId, Long appId, String pluginKey); +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalCorpConfigService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalCorpConfigService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalCorpConfigService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalCorpConfigService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalMessageLogService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalMessageLogService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalMessageLogService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalMessageLogService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalMessageService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalMessageService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalMessageService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalMessageService.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalPluginService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalPluginService.java new file mode 100644 index 0000000..b837790 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalPluginService.java @@ -0,0 +1,23 @@ +package dev.qingzhou.pushserver.service; + +import dev.qingzhou.pushserver.model.dto.portal.PortalPluginCreateRequest; +import dev.qingzhou.pushserver.model.vo.portal.PortalPluginVo; +import java.util.List; + +public interface PortalPluginService { + + // 1. 注册插件,返回包含 Token 的明文 + String createPlugin(PortalPluginCreateRequest request); + + // 2. 重置 Token,返回新 Token + String resetToken(Integer id); + + // 3. 切换状态 (启用/禁用) + void switchStatus(Integer id, Integer status); + + // 4. 插件列表 + List listPlugins(); + + // 5. 删除插件 + void deletePlugin(Integer id); +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalProxyConfigService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalProxyConfigService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalProxyConfigService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalProxyConfigService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalUserService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalUserService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalUserService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalUserService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/PortalWecomAppService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalWecomAppService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PortalWecomAppService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PortalWecomAppService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/PushService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/PushService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/PushService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/PushService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/SystemConfigService.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/SystemConfigService.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/SystemConfigService.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/SystemConfigService.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/DashboardServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/DashboardServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/DashboardServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/DashboardServiceImpl.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PluginManagerServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PluginManagerServiceImpl.java new file mode 100644 index 0000000..54616a3 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PluginManagerServiceImpl.java @@ -0,0 +1,275 @@ +package dev.qingzhou.pushserver.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +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.PluginMeta; +import dev.qingzhou.push.api.spi.PushPlugin; +import dev.qingzhou.push.api.spi.PushSender; +import dev.qingzhou.pushserver.grpc.PluginPacketDispatcher; +import dev.qingzhou.pushserver.mapper.portal.PortalWecomAppMapper; +import dev.qingzhou.pushserver.model.entity.portal.PortalWecomApp; +import dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper; +import dev.qingzhou.pushserver.model.entity.portal.PortalPluginActionLog; +import dev.qingzhou.pushserver.mapper.portal.PortalAppPluginConfigMapper; +import dev.qingzhou.pushserver.model.entity.portal.PortalAppPluginConfig; +import dev.qingzhou.pushserver.plugin.GrpcPluginProxy; +import dev.qingzhou.pushserver.service.PluginManagerService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PluginManagerServiceImpl implements PluginManagerService { + + private final List localPlugins; + private final PluginPacketDispatcher packetDispatcher; + private final PushSender pushSender; + private final PortalAppPluginConfigMapper appPluginConfigMapper; + private final ObjectMapper objectMapper; + private final PortalPluginActionLogMapper actionLogMapper; + private final PortalWecomAppMapper wecomAppMapper; + + private final Map remotePlugins = new ConcurrentHashMap<>(); + + @PostConstruct + public void init() { + log.info("Found {} local plugins", localPlugins.size()); + for (PushPlugin plugin : localPlugins) { + try { + plugin.init(pushSender); + log.info("Initialized local plugin: {}", plugin.getClass().getName()); + } catch (Exception e) { + log.error("Failed to init local plugin {}", plugin.getClass().getName(), e); + } + } + } + + @Override + public void registerRemotePlugin(String pluginKey, PluginMeta meta) { + log.info("Registering remote plugin: {}", pluginKey); + GrpcPluginProxy proxy = new GrpcPluginProxy(pluginKey, meta, packetDispatcher); + remotePlugins.put(pluginKey, proxy); + } + + @Override + public void unregisterRemotePlugin(String pluginKey) { + log.info("Unregistering remote plugin: {}", pluginKey); + remotePlugins.remove(pluginKey); + } + + @Override + public List getAllPlugins() { + List all = new ArrayList<>(localPlugins); + all.addAll(remotePlugins.values()); + return all; + } + + @Override + public List getLocalPlugins() { + return localPlugins; + } + + /** + * Dispatch a user action to the plugins that are both + * 1) registered (local or active remote), and + * 2) enabled for the given app with their own configuration. + */ + @Override + public void dispatch(ActionContext context) { + if (context == null) { + log.warn("Dispatch skipped because context is null"); + return; + } + + Long appId = parseAppId(context.getAppId()); + Map activeConfigs = Collections.emptyMap(); + + if (appId != null) { + activeConfigs = appPluginConfigMapper.selectList( + new LambdaQueryWrapper() + .eq(PortalAppPluginConfig::getAppId, appId) + .eq(PortalAppPluginConfig::getStatus, 1) + ).stream().collect(Collectors.toMap( + PortalAppPluginConfig::getPluginKey, + Function.identity(), + (existing, replacement) -> replacement + )); + } + + boolean handled = false; + for (PushPlugin plugin : getAllPlugins()) { + PluginMeta meta = plugin.getMeta(); + String pluginKey = meta != null && StringUtils.hasText(meta.getId()) + ? meta.getId() + : plugin.getClass().getSimpleName(); + + if (appId != null && !activeConfigs.containsKey(pluginKey)) { + log.debug("Skip plugin {} for app {} (not configured or disabled)", pluginKey, appId); + continue; + } + + Map pluginConfig = buildPluginConfig( + meta, + activeConfigs.get(pluginKey), + context.getPluginConfig() + ); + + ActionContext pluginContext = cloneContextWithConfig(context, pluginConfig); + + // 记录接收 + 请求快照(若已存在则更新) + upsertActionLog(pluginKey, pluginContext, 0, "RECEIVED"); + + try { + if (plugin.supports(pluginContext)) { + String name = meta != null && StringUtils.hasText(meta.getName()) + ? meta.getName() + : plugin.getClass().getSimpleName(); + + log.info("Dispatching event {} (app {}) to plugin {}", pluginContext.getEventId(), appId, name); + plugin.handle(pluginContext); + + // 仅对本地插件记录最终状态;远程插件由其 ActionAck 写日志,避免重复 + if (!(plugin instanceof GrpcPluginProxy)) { + upsertActionLog(pluginKey, pluginContext, 2, "SUCCESS"); + } + handled = true; + } + } catch (Exception e) { + log.error("Plugin {} failed to handle event {}", pluginKey, pluginContext.getEventId(), e); + if (!(plugin instanceof GrpcPluginProxy)) { + upsertActionLog(pluginKey, pluginContext, 3, "FAILED: " + e.getMessage()); + } + } + } + + if (!handled) { + log.warn("No plugin handled event {} (app {})", context.getEventId(), appId); + } + } + + private ActionContext cloneContextWithConfig(ActionContext source, Map pluginConfig) { + return ActionContext.builder() + .eventId(source.getEventId()) + .appId(source.getAppId()) + .userId(source.getUserId()) + .userName(source.getUserName()) + .type(source.getType()) + .content(source.getContent()) + .pluginConfig(pluginConfig) + .build(); + } + + private Map buildPluginConfig(PluginMeta meta, + PortalAppPluginConfig storedConfig, + Map runtimeOverrides) { + Map result = new HashMap<>(); + + if (meta != null && meta.getConfigFields() != null) { + for (ConfigField field : meta.getConfigFields()) { + if (StringUtils.hasText(field.getName()) && field.getDefaultValue() != null) { + result.put(field.getName(), field.getDefaultValue()); + } + } + } + + if (storedConfig != null && StringUtils.hasText(storedConfig.getConfigJson())) { + result.putAll(parseConfigJson(storedConfig.getConfigJson())); + } + + if (runtimeOverrides != null && !runtimeOverrides.isEmpty()) { + result.putAll(runtimeOverrides); + } + + return result; + } + + private Map parseConfigJson(String json) { + try { + Map raw = objectMapper.readValue(json, new TypeReference>() {}); + Map parsed = new HashMap<>(); + if (raw != null) { + raw.forEach((k, v) -> parsed.put(k, v == null ? null : String.valueOf(v))); + } + return parsed; + } catch (Exception e) { + log.warn("Failed to parse plugin config json: {}", json, e); + return Collections.emptyMap(); + } + } + + private Long parseAppId(String appId) { + if (!StringUtils.hasText(appId)) { + return null; + } + try { + return Long.parseLong(appId.trim()); + } catch (NumberFormatException ex) { + log.warn("Invalid appId on ActionContext: {}", appId); + return null; + } + } + + private void upsertActionLog(String pluginKey, ActionContext ctx, int status, String message) { + String eventId = ctx.getEventId(); + PortalPluginActionLog entity = new PortalPluginActionLog(); + entity.setPluginKey(pluginKey); + entity.setEventId(eventId); + entity.setStatus(status); + entity.setMessage(message); + entity.setAppId(ctx.getAppId()); + entity.setAppName(resolveAppName(ctx.getAppId())); + entity.setUserId(ctx.getUserId()); + entity.setType(ctx.getType()); + entity.setContent(ctx.getContent()); + entity.setPluginConfig(writeJson(ctx.getPluginConfig())); + entity.setCreatedAt(System.currentTimeMillis()); + try { + int updated = actionLogMapper.update(entity, + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(PortalPluginActionLog::getPluginKey, pluginKey) + .eq(PortalPluginActionLog::getEventId, eventId)); + if (updated == 0) { + actionLogMapper.insert(entity); + } + } catch (Exception ex) { + log.warn("Failed to upsert plugin action log: pluginKey={}, eventId={}, status={}, msg={}", pluginKey, eventId, status, message, ex); + } + } + + private String writeJson(Map map) { + if (map == null || map.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(map); + } catch (Exception e) { + return null; + } + } + + private String resolveAppName(String appId) { + if (!StringUtils.hasText(appId)) { + return null; + } + try { + PortalWecomApp app = wecomAppMapper.selectById(Long.parseLong(appId)); + return app != null ? app.getName() : null; + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAccessTokenServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAccessTokenServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalAccessTokenServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAccessTokenServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppApiKeyServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppApiKeyServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppApiKeyServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppApiKeyServiceImpl.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppPluginServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppPluginServiceImpl.java new file mode 100644 index 0000000..bcdfc24 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalAppPluginServiceImpl.java @@ -0,0 +1,128 @@ +package dev.qingzhou.pushserver.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import dev.qingzhou.push.api.model.PluginMeta; +import dev.qingzhou.push.api.spi.PushPlugin; +import dev.qingzhou.pushserver.mapper.portal.PortalAppPluginConfigMapper; +import dev.qingzhou.pushserver.model.dto.portal.AppPluginConfigSaveRequest; +import dev.qingzhou.pushserver.model.entity.portal.PortalAppPluginConfig; +import dev.qingzhou.pushserver.model.vo.portal.PortalAppPluginConfigVo; +import dev.qingzhou.pushserver.service.PluginManagerService; +import dev.qingzhou.pushserver.service.PortalAppPluginService; +import dev.qingzhou.pushserver.service.PortalWecomAppService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PortalAppPluginServiceImpl implements PortalAppPluginService { + + private final PortalWecomAppService appService; + private final PluginManagerService pluginManagerService; + private final PortalAppPluginConfigMapper configMapper; + + @Override + public List listByApp(Long userId, Long appId) { + // Ensure user owns the app + appService.requireByUser(userId, appId); + + // Get all active plugins from memory (Local + Remote) + List allPlugins = pluginManagerService.getAllPlugins(); + + // Get stored configs for this app + List storedConfigs = configMapper.selectList( + new LambdaQueryWrapper() + .eq(PortalAppPluginConfig::getAppId, appId) + ); + Map configMap = storedConfigs.stream() + .collect(Collectors.toMap(PortalAppPluginConfig::getPluginKey, Function.identity())); + + List result = new ArrayList<>(); + for (PushPlugin plugin : allPlugins) { + PluginMeta meta = plugin.getMeta(); + if (meta == null) { + // Skip plugins without meta (shouldn't happen usually for valid plugins) + continue; + } + // Use ID from meta as the key. If ID is missing, fallback to class name or specific logic? + // Usually meta.id is the unique key. + // WAIT: In PluginManagerService, registerRemotePlugin uses a pluginKey. + // But PushPlugin interface has getMeta(). + // For remote plugins, the proxy should return the meta provided during registration. + + String pluginKey = meta.getId(); + // Ideally pluginKey should be consistent. Assuming meta.id is the key. + + PortalAppPluginConfig stored = configMap.get(pluginKey); + + PortalAppPluginConfigVo vo = PortalAppPluginConfigVo.builder() + .pluginKey(pluginKey) + .name(meta.getName()) + .description(meta.getDescription()) + .meta(meta) + .configJson(stored != null ? stored.getConfigJson() : null) + .status(stored != null ? stored.getStatus() : 0) // Default to disabled if not configured + .updatedAt(stored != null ? stored.getUpdatedAt() : null) + .build(); + result.add(vo); + } + return result; + } + + @Override + @Transactional + public void saveConfig(Long userId, Long appId, AppPluginConfigSaveRequest request) { + appService.requireByUser(userId, appId); + + String pluginKey = request.getPluginKey(); + + PortalAppPluginConfig existing = configMapper.selectOne( + new LambdaQueryWrapper() + .eq(PortalAppPluginConfig::getAppId, appId) + .eq(PortalAppPluginConfig::getPluginKey, pluginKey) + ); + + long now = System.currentTimeMillis(); + + if (existing == null) { + existing = new PortalAppPluginConfig(); + existing.setAppId(appId); + existing.setPluginKey(pluginKey); + existing.setConfigJson(request.getConfigJson()); + existing.setStatus(request.getStatus() != null ? request.getStatus() : 1); + existing.setCreatedAt(now); + existing.setUpdatedAt(now); + configMapper.insert(existing); + } else { + if (request.getConfigJson() != null) { + existing.setConfigJson(request.getConfigJson()); + } + if (request.getStatus() != null) { + existing.setStatus(request.getStatus()); + } + existing.setUpdatedAt(now); + configMapper.updateById(existing); + } + } + + @Override + @Transactional + public void deleteConfig(Long userId, Long appId, String pluginKey) { + appService.requireByUser(userId, appId); + + configMapper.delete( + new LambdaQueryWrapper() + .eq(PortalAppPluginConfig::getAppId, appId) + .eq(PortalAppPluginConfig::getPluginKey, pluginKey) + ); + } +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalCorpConfigServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalCorpConfigServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalCorpConfigServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalCorpConfigServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageLogServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageLogServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageLogServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageLogServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalMessageServiceImpl.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalPluginServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalPluginServiceImpl.java new file mode 100644 index 0000000..1a9210d --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalPluginServiceImpl.java @@ -0,0 +1,146 @@ +package dev.qingzhou.pushserver.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import dev.qingzhou.pushserver.exception.PortalException; +import dev.qingzhou.pushserver.exception.PortalStatus; +import dev.qingzhou.pushserver.mapper.portal.PortalPluginMapper; +import dev.qingzhou.pushserver.model.dto.portal.PortalPluginCreateRequest; +import dev.qingzhou.pushserver.model.entity.portal.PortalPlugin; +import dev.qingzhou.pushserver.model.vo.portal.PortalPluginVo; +import dev.qingzhou.pushserver.service.PortalPluginService; +import dev.qingzhou.pushserver.utils.TokenUtils; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import dev.qingzhou.pushserver.service.PluginManagerService; +import dev.qingzhou.pushserver.grpc.PluginConnectionManager; +import dev.qingzhou.push.api.spi.PushPlugin; +import dev.qingzhou.push.api.model.PluginMeta; +import java.util.ArrayList; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PortalPluginServiceImpl implements PortalPluginService { + + private final PortalPluginMapper pluginMapper; + private final PluginManagerService pluginManagerService; + private final PluginConnectionManager connectionManager; + + @Override + @Transactional(rollbackFor = Exception.class) + public String createPlugin(PortalPluginCreateRequest request) { + // ... (unchanged) + // 1. Check duplicate key + if (pluginMapper.exists(new LambdaQueryWrapper() + .eq(PortalPlugin::getPluginKey, request.getPluginKey()))) { + throw new PortalException(PortalStatus.CONFLICT, "Plugin key already exists"); + } + + // 2. Generate Token + String token = TokenUtils.generateToken("sk_live_"); + + // 3. Save + PortalPlugin plugin = new PortalPlugin(); + plugin.setPluginKey(request.getPluginKey()); + plugin.setName(request.getName()); + plugin.setDescription(request.getDescription()); + plugin.setToken(token); + plugin.setStatus(1); // Default Enabled + plugin.setCreatedAt(System.currentTimeMillis()); + plugin.setUpdatedAt(System.currentTimeMillis()); + + pluginMapper.insert(plugin); + + return token; + } + + // ... resetToken and switchStatus (unchanged - assume they are fine or I just overwrite whole class to be safe) + + @Override + @Transactional(rollbackFor = Exception.class) + public String resetToken(Integer id) { + PortalPlugin plugin = pluginMapper.selectById(id); + if (plugin == null) { + throw new PortalException(PortalStatus.NOT_FOUND, "Plugin not found"); + } + + String newToken = TokenUtils.generateToken("sk_live_"); + plugin.setToken(newToken); + plugin.setUpdatedAt(System.currentTimeMillis()); + pluginMapper.updateById(plugin); + + return newToken; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void switchStatus(Integer id, Integer status) { + PortalPlugin plugin = pluginMapper.selectById(id); + if (plugin == null) { + throw new PortalException(PortalStatus.NOT_FOUND, "Plugin not found"); + } + + if (status != 0 && status != 1) { + throw new PortalException(PortalStatus.BAD_REQUEST, "Invalid status"); + } + + plugin.setStatus(status); + plugin.setUpdatedAt(System.currentTimeMillis()); + pluginMapper.updateById(plugin); + } + + @Override + public List listPlugins() { + // 1. Remote Plugins from DB + List dbList = pluginMapper.selectList(new LambdaQueryWrapper() + .orderByDesc(PortalPlugin::getCreatedAt)); + + List result = new ArrayList<>(); + + for (PortalPlugin p : dbList) { + result.add(PortalPluginVo.builder() + .id(p.getId()) + .pluginKey(p.getPluginKey()) + .name(p.getName()) + .description(p.getDescription()) + .status(p.getStatus()) + .createdAt(p.getCreatedAt()) + .isConnected(connectionManager.isConnected(p.getPluginKey())) + .isBuiltin(false) + .build()); + } + + // 2. Local Plugins from Manager + List localPlugins = pluginManagerService.getLocalPlugins(); + for (PushPlugin p : localPlugins) { + PluginMeta meta = p.getMeta(); + result.add(PortalPluginVo.builder() + .id(-1) // Negative ID for builtin + .pluginKey(meta.getId()) + .name(meta.getName()) + .description(meta.getDescription()) + .status(1) // Always enabled for now, or manage state elsewhere + .createdAt(0L) + .isConnected(true) // Builtin is always connected + .isBuiltin(true) + .build()); + } + + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePlugin(Integer id) { + PortalPlugin plugin = pluginMapper.selectById(id); + if (plugin == null) { + throw new PortalException(PortalStatus.NOT_FOUND, "Plugin not found"); + } + pluginMapper.deleteById(id); + } +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalProxyConfigServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalProxyConfigServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalProxyConfigServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalProxyConfigServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalUserServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalUserServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalUserServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalUserServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PortalWecomAppServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalWecomAppServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PortalWecomAppServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PortalWecomAppServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/PushServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PushServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/PushServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/PushServiceImpl.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/ServerPushSender.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/ServerPushSender.java new file mode 100644 index 0000000..fddf850 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/ServerPushSender.java @@ -0,0 +1,28 @@ +package dev.qingzhou.pushserver.service.impl; + +import dev.qingzhou.push.api.model.PushMessage; +import dev.qingzhou.push.api.spi.PushSender; +import dev.qingzhou.pushserver.model.dto.openapi.PushRequest; +import dev.qingzhou.pushserver.service.PushService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ServerPushSender implements PushSender { + + private final PushService pushService; + + @Override + public void send(PushMessage message) { + PushRequest req = new PushRequest(); + req.setTarget(message.getTargetUserId()); + req.setType(message.getType() != null ? message.getType() : "text"); + req.setContent(message.getContent()); + req.setTitle(message.getTitle()); + req.setUrl(message.getUrl()); + // Map other fields... + + pushService.push(req); + } +} diff --git a/src/main/java/dev/qingzhou/pushserver/service/impl/SystemConfigServiceImpl.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/SystemConfigServiceImpl.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/service/impl/SystemConfigServiceImpl.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/service/impl/SystemConfigServiceImpl.java diff --git a/src/main/java/dev/qingzhou/pushserver/utils/CasTokenBucket.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/utils/CasTokenBucket.java similarity index 100% rename from src/main/java/dev/qingzhou/pushserver/utils/CasTokenBucket.java rename to push-server-core/src/main/java/dev/qingzhou/pushserver/utils/CasTokenBucket.java diff --git a/push-server-core/src/main/java/dev/qingzhou/pushserver/utils/TokenUtils.java b/push-server-core/src/main/java/dev/qingzhou/pushserver/utils/TokenUtils.java new file mode 100644 index 0000000..4a58fd0 --- /dev/null +++ b/push-server-core/src/main/java/dev/qingzhou/pushserver/utils/TokenUtils.java @@ -0,0 +1,18 @@ +package dev.qingzhou.pushserver.utils; + +import java.security.SecureRandom; +import java.util.Base64; + +public class TokenUtils { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + /** + * 生成带前缀的随机 Token (例如: sk_live_abc123...) + */ + public static String generateToken(String prefix) { + byte[] randomBytes = new byte[24]; + SECURE_RANDOM.nextBytes(randomBytes); + String randomStr = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + return prefix + randomStr; + } +} diff --git a/src/main/resources/META-INF/native-image/reachability-metadata.json b/push-server-core/src/main/resources/META-INF/native-image/reachability-metadata.json similarity index 86% rename from src/main/resources/META-INF/native-image/reachability-metadata.json rename to push-server-core/src/main/resources/META-INF/native-image/reachability-metadata.json index 1c46924..b00cc61 100644 --- a/src/main/resources/META-INF/native-image/reachability-metadata.json +++ b/push-server-core/src/main/resources/META-INF/native-image/reachability-metadata.json @@ -1,5 +1,8 @@ { "reflection": [ + { + "type": "android.app.Application" + }, { "type": "boolean" }, @@ -130,6 +133,9 @@ } ] }, + { + "type": "com.baomidou.mybatisplus.core.conditions.AbstractLambdaWrapper" + }, { "type": "com.baomidou.mybatisplus.core.conditions.AbstractWrapper", "methods": [ @@ -161,6 +167,10 @@ { "type": "com.baomidou.mybatisplus.core.conditions.Wrapper", "methods": [ + { + "name": "getSqlSet", + "parameterTypes": [] + }, { "name": "isEmptyOfNormal", "parameterTypes": [] @@ -183,6 +193,15 @@ { "type": "com.baomidou.mybatisplus.core.conditions.interfaces.Nested" }, + { + "type": "com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper", + "methods": [ + { + "name": "getSqlSelect", + "parameterTypes": [] + } + ] + }, { "type": "com.baomidou.mybatisplus.core.conditions.query.Query" }, @@ -210,6 +229,13 @@ "com.baomidou.mybatisplus.core.conditions.Wrapper", "boolean" ] + }, + { + "name": "selectPage", + "parameterTypes": [ + "com.baomidou.mybatisplus.core.metadata.IPage", + "com.baomidou.mybatisplus.core.conditions.Wrapper" + ] } ] }, @@ -218,16 +244,9 @@ }, { "type": "com.baomidou.mybatisplus.core.override.MybatisMapperProxy", - "methods": [ - { - "name": "getMapperInterface", - "parameterTypes": [] - }, - { - "name": "getSqlSession", - "parameterTypes": [] - } - ] + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true }, { "type": "com.baomidou.mybatisplus.core.toolkit.Wrappers$EmptyWrapper" @@ -384,6 +403,7 @@ }, { "type": "com.github.benmanes.caffeine.cache.PS", + "allDeclaredConstructors": true, "fields": [ { "name": "key" @@ -393,22 +413,30 @@ } ] }, + { + "type": "com.github.benmanes.caffeine.cache.PSL", + "allDeclaredConstructors": true + }, { "type": "com.github.benmanes.caffeine.cache.PSW", + "allDeclaredConstructors": true, "fields": [ { "name": "writeTime" } - ], - "methods": [ - { - "name": "", - "parameterTypes": [] - } ] }, + { + "type": "com.github.benmanes.caffeine.cache.SS", + "allDeclaredConstructors": true + }, + { + "type": "com.github.benmanes.caffeine.cache.SSL", + "allDeclaredConstructors": true + }, { "type": "com.github.benmanes.caffeine.cache.SSW", + "allDeclaredConstructors": true, "fields": [ { "name": "FACTORY" @@ -421,6 +449,15 @@ { "type": "com.google.gson.Gson" }, + { + "type": "com.google.protobuf.ExtensionRegistry", + "methods": [ + { + "name": "getEmptyRegistry", + "parameterTypes": [] + } + ] + }, { "type": "com.rometools.rome.feed.WireFeed" }, @@ -487,6 +524,26 @@ } ] }, + { + "type": "com.sun.crypto.provider.HKDFKeyDerivation$HKDFSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + "javax.crypto.KDFParameters" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HmacCore$HmacSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "com.sun.crypto.provider.TlsKeyMaterialGenerator", "methods": [ @@ -538,6 +595,86 @@ { "type": "com.zaxxer.hikari.pool.PoolEntry" }, + { + "type": "dev.qingzhou.push.api.grpc.PluginGatewayGrpc$AsyncService" + }, + { + "type": "dev.qingzhou.push.api.grpc.PluginGatewayGrpc$PluginGatewayImplBase" + }, + { + "type": "dev.qingzhou.push.api.model.ConfigField", + "methods": [ + { + "name": "getDefaultValue", + "parameterTypes": [] + }, + { + "name": "getDescription", + "parameterTypes": [] + }, + { + "name": "getLabel", + "parameterTypes": [] + }, + { + "name": "getName", + "parameterTypes": [] + }, + { + "name": "getOptions", + "parameterTypes": [] + }, + { + "name": "getType", + "parameterTypes": [] + }, + { + "name": "isRequired", + "parameterTypes": [] + } + ] + }, + { + "type": "dev.qingzhou.push.api.model.ConfigField[]" + }, + { + "type": "dev.qingzhou.push.api.model.ConfigType" + }, + { + "type": "dev.qingzhou.push.api.model.PluginMeta", + "methods": [ + { + "name": "getConfigFields", + "parameterTypes": [] + }, + { + "name": "getDescription", + "parameterTypes": [] + }, + { + "name": "getId", + "parameterTypes": [] + }, + { + "name": "getMaxConcurrency", + "parameterTypes": [] + }, + { + "name": "getName", + "parameterTypes": [] + }, + { + "name": "getVersion", + "parameterTypes": [] + } + ] + }, + { + "type": "dev.qingzhou.push.api.spi.PushPlugin" + }, + { + "type": "dev.qingzhou.push.api.spi.PushSender" + }, { "type": "dev.qingzhou.pushserver.PushServerApplication", "methods": [ @@ -581,6 +718,53 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.config.GrpcServerConfig", + "fields": [ + { + "name": "port" + } + ], + "methods": [ + { + "name": "start", + "parameterTypes": [] + }, + { + "name": "stop", + "parameterTypes": [] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.config.GrpcServerConfig$$SpringCGLIB$$0", + "fields": [ + { + "name": "$$beanFactory" + }, + { + "name": "CGLIB$CALLBACK_FILTER" + }, + { + "name": "CGLIB$FACTORY_DATA" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.grpc.PluginGatewayImpl", + "dev.qingzhou.pushserver.grpc.PluginAuthInterceptor" + ] + }, + { + "name": "CGLIB$SET_STATIC_CALLBACKS", + "parameterTypes": [ + "org.springframework.cglib.proxy.Callback[]" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.config.JsonDtoPackageHints", "methods": [ @@ -923,6 +1107,35 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.config.SchedulingConfig" + }, + { + "type": "dev.qingzhou.pushserver.config.SchedulingConfig$$SpringCGLIB$$0", + "fields": [ + { + "name": "$$beanFactory" + }, + { + "name": "CGLIB$CALLBACK_FILTER" + }, + { + "name": "CGLIB$FACTORY_DATA" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "CGLIB$SET_STATIC_CALLBACKS", + "parameterTypes": [ + "org.springframework.cglib.proxy.Callback[]" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.config.WebConfig" }, @@ -962,6 +1175,23 @@ "parameterTypes": [ "dev.qingzhou.pushserver.service.SystemConfigService" ] + }, + { + "name": "captcha", + "parameterTypes": [] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.controller.CaptchaController$CaptchaResponse", + "methods": [ + { + "name": "enabled", + "parameterTypes": [] + }, + { + "name": "siteKey", + "parameterTypes": [] } ] }, @@ -1032,6 +1262,13 @@ "jakarta.servlet.http.HttpSession" ] }, + { + "name": "deleteApiKey", + "parameterTypes": [ + "java.lang.Long", + "jakarta.servlet.http.HttpSession" + ] + }, { "name": "getApiKey", "parameterTypes": [ @@ -1052,6 +1289,14 @@ "jakarta.servlet.http.HttpSession" ] }, + { + "name": "update", + "parameterTypes": [ + "java.lang.Long", + "dev.qingzhou.pushserver.model.dto.portal.PortalAppUpdateRequest", + "jakarta.servlet.http.HttpSession" + ] + }, { "name": "updateApiKey", "parameterTypes": [ @@ -1062,6 +1307,32 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.controller.PortalAppPluginController", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.service.PortalAppPluginService" + ] + }, + { + "name": "list", + "parameterTypes": [ + "java.lang.Long", + "jakarta.servlet.http.HttpSession" + ] + }, + { + "name": "saveConfig", + "parameterTypes": [ + "java.lang.Long", + "dev.qingzhou.pushserver.model.dto.portal.AppPluginConfigSaveRequest", + "jakarta.servlet.http.HttpSession" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.controller.PortalAuthController", "methods": [ @@ -1125,6 +1396,12 @@ { "name": "getInitStatus", "parameterTypes": [] + }, + { + "name": "initialize", + "parameterTypes": [ + "dev.qingzhou.pushserver.model.dto.portal.PortalInitRequest" + ] } ] }, @@ -1176,18 +1453,65 @@ ] }, { - "type": "dev.qingzhou.pushserver.controller.PortalProxyController", + "type": "dev.qingzhou.pushserver.controller.PortalPluginController", "methods": [ { "name": "", "parameterTypes": [ - "dev.qingzhou.pushserver.service.PortalProxyConfigService" + "dev.qingzhou.pushserver.service.PortalPluginService" ] }, { - "name": "getProxy", - "parameterTypes": [ - "jakarta.servlet.http.HttpSession" + "name": "list", + "parameterTypes": [] + }, + { + "name": "switchStatus", + "parameterTypes": [ + "java.util.Map" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.controller.PortalPluginLogController", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper", + "dev.qingzhou.pushserver.mapper.portal.PortalPluginHeartbeatLogMapper" + ] + }, + { + "name": "listActions", + "parameterTypes": [ + "java.lang.String", + "int", + "int" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.controller.PortalProxyController", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.service.PortalProxyConfigService" + ] + }, + { + "name": "delete", + "parameterTypes": [ + "jakarta.servlet.http.HttpSession" + ] + }, + { + "name": "getProxy", + "parameterTypes": [ + "jakarta.servlet.http.HttpSession" ] }, { @@ -1217,9 +1541,19 @@ "name": "getIgnoreVersion", "parameterTypes": [] }, + { + "name": "getTurnstileConfig", + "parameterTypes": [] + }, { "name": "getVersion", "parameterTypes": [] + }, + { + "name": "updateTurnstileConfig", + "parameterTypes": [ + "dev.qingzhou.pushserver.model.dto.portal.TurnstileConfigRequest" + ] } ] }, @@ -1261,7 +1595,19 @@ "name": "", "parameterTypes": [ "dev.qingzhou.pushserver.service.PortalWecomAppService", - "dev.qingzhou.pushserver.service.PortalCorpConfigService" + "dev.qingzhou.pushserver.service.PortalCorpConfigService", + "dev.qingzhou.pushserver.service.PluginManagerService", + "org.springframework.core.task.TaskExecutor" + ] + }, + { + "name": "handleMessage", + "parameterTypes": [ + "java.lang.Long", + "java.lang.String", + "java.lang.String", + "java.lang.String", + "java.lang.String" ] } ] @@ -1277,6 +1623,41 @@ }, { "type": "dev.qingzhou.pushserver.exception.PortalExceptionHandler", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "handlePortalException", + "parameterTypes": [ + "dev.qingzhou.pushserver.exception.PortalException" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.grpc.GrpcAdapter", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.grpc.PluginAuthInterceptor", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.mapper.portal.PortalPluginMapper" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.grpc.PluginConnectionManager", "methods": [ { "name": "", @@ -1284,6 +1665,33 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.grpc.PluginGatewayImpl", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.grpc.PluginConnectionManager", + "dev.qingzhou.pushserver.grpc.GrpcAdapter", + "dev.qingzhou.pushserver.service.PushService", + "dev.qingzhou.pushserver.service.PluginManagerService", + "dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper", + "dev.qingzhou.pushserver.mapper.portal.PortalPluginHeartbeatLogMapper" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.grpc.PluginPacketDispatcher", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.grpc.PluginConnectionManager" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.manager.wecom.WecomAgentInfo", "methods": [ @@ -1566,12 +1974,24 @@ { "type": "dev.qingzhou.pushserver.mapper.portal.PortalAppApiKeyMapper" }, + { + "type": "dev.qingzhou.pushserver.mapper.portal.PortalAppPluginConfigMapper" + }, { "type": "dev.qingzhou.pushserver.mapper.portal.PortalCorpConfigMapper" }, { "type": "dev.qingzhou.pushserver.mapper.portal.PortalMessageLogMapper" }, + { + "type": "dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper" + }, + { + "type": "dev.qingzhou.pushserver.mapper.portal.PortalPluginHeartbeatLogMapper" + }, + { + "type": "dev.qingzhou.pushserver.mapper.portal.PortalPluginMapper" + }, { "type": "dev.qingzhou.pushserver.mapper.portal.PortalProxyConfigMapper" }, @@ -1591,23 +2011,85 @@ "name": "", "parameterTypes": [] }, + { + "name": "setArticles", + "parameterTypes": [ + "java.util.List" + ] + }, + { + "name": "setBtnText", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setContent", "parameterTypes": [ "java.lang.String" ] }, + { + "name": "setDescription", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setMsgType", "parameterTypes": [ "dev.qingzhou.pushserver.model.dto.portal.PortalMessageType" ] }, + { + "name": "setTitle", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setToAll", "parameterTypes": [ "java.lang.Boolean" ] + }, + { + "name": "setUrl", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.model.dto.portal.AppPluginConfigSaveRequest", + "fields": [ + { + "name": "pluginKey" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "setConfigJson", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setPluginKey", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setStatus", + "parameterTypes": [ + "java.lang.Integer" + ] } ] }, @@ -1660,6 +2142,27 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.dto.portal.PortalAppUpdateRequest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "setEncodingAesKey", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setToken", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.dto.portal.PortalCorpConfigRequest", "fields": [ @@ -1680,6 +2183,53 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.dto.portal.PortalInitRequest", + "fields": [ + { + "name": "password" + }, + { + "name": "username" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "setPassword", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setTurnstileEnabled", + "parameterTypes": [ + "boolean" + ] + }, + { + "name": "setTurnstileSecretKey", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setTurnstileSiteKey", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setUsername", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.dto.portal.PortalLoginRequest", "methods": [ @@ -1731,6 +2281,12 @@ "java.util.List" ] }, + { + "name": "setBtnText", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setContent", "parameterTypes": [ @@ -1761,6 +2317,12 @@ "java.lang.Boolean" ] }, + { + "name": "setToUser", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setUrl", "parameterTypes": [ @@ -1864,6 +2426,45 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.dto.portal.TurnstileConfigRequest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "getSecretKey", + "parameterTypes": [] + }, + { + "name": "getSiteKey", + "parameterTypes": [] + }, + { + "name": "isEnabled", + "parameterTypes": [] + }, + { + "name": "setEnabled", + "parameterTypes": [ + "boolean" + ] + }, + { + "name": "setSecretKey", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setSiteKey", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.entity.portal.PortalAppApiKey", "methods": [ @@ -1943,6 +2544,85 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.entity.portal.PortalAppPluginConfig", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "getAppId", + "parameterTypes": [] + }, + { + "name": "getConfigJson", + "parameterTypes": [] + }, + { + "name": "getCreatedAt", + "parameterTypes": [] + }, + { + "name": "getId", + "parameterTypes": [] + }, + { + "name": "getPluginKey", + "parameterTypes": [] + }, + { + "name": "getStatus", + "parameterTypes": [] + }, + { + "name": "getUpdatedAt", + "parameterTypes": [] + }, + { + "name": "setAppId", + "parameterTypes": [ + "java.lang.Long" + ] + }, + { + "name": "setConfigJson", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setCreatedAt", + "parameterTypes": [ + "java.lang.Long" + ] + }, + { + "name": "setId", + "parameterTypes": [ + "java.lang.Long" + ] + }, + { + "name": "setPluginKey", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setStatus", + "parameterTypes": [ + "java.lang.Integer" + ] + }, + { + "name": "setUpdatedAt", + "parameterTypes": [ + "java.lang.Long" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.entity.portal.PortalCorpConfig", "methods": [ @@ -2108,49 +2788,193 @@ ] }, { - "name": "setId", + "name": "setErrorMessage", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setId", + "parameterTypes": [ + "java.lang.Long" + ] + }, + { + "name": "setMsgType", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setRequestJson", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setResponseJson", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setSuccess", + "parameterTypes": [ + "java.lang.Integer" + ] + }, + { + "name": "setTitle", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setToAll", + "parameterTypes": [ + "java.lang.Integer" + ] + }, + { + "name": "setToUser", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setUrl", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setUserId", + "parameterTypes": [ + "java.lang.Long" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.model.entity.portal.PortalPlugin" + }, + { + "type": "dev.qingzhou.pushserver.model.entity.portal.PortalPluginActionLog", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "getAppId", + "parameterTypes": [] + }, + { + "name": "getAppName", + "parameterTypes": [] + }, + { + "name": "getContent", + "parameterTypes": [] + }, + { + "name": "getCreatedAt", + "parameterTypes": [] + }, + { + "name": "getEventId", + "parameterTypes": [] + }, + { + "name": "getId", + "parameterTypes": [] + }, + { + "name": "getMessage", + "parameterTypes": [] + }, + { + "name": "getPluginConfig", + "parameterTypes": [] + }, + { + "name": "getPluginKey", + "parameterTypes": [] + }, + { + "name": "getStatus", + "parameterTypes": [] + }, + { + "name": "getType", + "parameterTypes": [] + }, + { + "name": "getUserId", + "parameterTypes": [] + }, + { + "name": "setAppId", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setAppName", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setContent", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "setCreatedAt", "parameterTypes": [ "java.lang.Long" ] }, { - "name": "setMsgType", + "name": "setEventId", "parameterTypes": [ "java.lang.String" ] }, { - "name": "setRequestJson", + "name": "setId", "parameterTypes": [ - "java.lang.String" + "java.lang.Long" ] }, { - "name": "setResponseJson", + "name": "setMessage", "parameterTypes": [ "java.lang.String" ] }, { - "name": "setSuccess", + "name": "setPluginConfig", "parameterTypes": [ - "java.lang.Integer" + "java.lang.String" ] }, { - "name": "setTitle", + "name": "setPluginKey", "parameterTypes": [ "java.lang.String" ] }, { - "name": "setToAll", + "name": "setStatus", "parameterTypes": [ "java.lang.Integer" ] }, { - "name": "setUrl", + "name": "setType", "parameterTypes": [ "java.lang.String" ] @@ -2158,11 +2982,14 @@ { "name": "setUserId", "parameterTypes": [ - "java.lang.Long" + "java.lang.String" ] } ] }, + { + "type": "dev.qingzhou.pushserver.model.entity.portal.PortalPluginHeartbeatLog" + }, { "type": "dev.qingzhou.pushserver.model.entity.portal.PortalProxyConfig", "methods": [ @@ -2289,6 +3116,22 @@ "name": "", "parameterTypes": [] }, + { + "name": "getConfigKey", + "parameterTypes": [] + }, + { + "name": "getConfigValue", + "parameterTypes": [] + }, + { + "name": "getId", + "parameterTypes": [] + }, + { + "name": "getUpdatedAt", + "parameterTypes": [] + }, { "name": "setConfigKey", "parameterTypes": [ @@ -2322,6 +3165,26 @@ "name": "", "parameterTypes": [] }, + { + "name": "getAccount", + "parameterTypes": [] + }, + { + "name": "getCreatedAt", + "parameterTypes": [] + }, + { + "name": "getId", + "parameterTypes": [] + }, + { + "name": "getPasswordHash", + "parameterTypes": [] + }, + { + "name": "getUpdatedAt", + "parameterTypes": [] + }, { "name": "setAccount", "parameterTypes": [ @@ -2429,6 +3292,12 @@ "java.lang.String" ] }, + { + "name": "setEncodingAesKey", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setId", "parameterTypes": [ @@ -2447,6 +3316,12 @@ "java.lang.String" ] }, + { + "name": "setToken", + "parameterTypes": [ + "java.lang.String" + ] + }, { "name": "setUpdatedAt", "parameterTypes": [ @@ -2474,6 +3349,19 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.vo.portal.DashboardChartsResponse$DistributionSlice", + "methods": [ + { + "name": "getName", + "parameterTypes": [] + }, + { + "name": "getValue", + "parameterTypes": [] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.vo.portal.DashboardChartsResponse$TrendPoint", "methods": [ @@ -2487,6 +3375,31 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.vo.portal.DashboardLogResponse", + "methods": [ + { + "name": "getAppName", + "parameterTypes": [] + }, + { + "name": "getErrorMsg", + "parameterTypes": [] + }, + { + "name": "getReceiver", + "parameterTypes": [] + }, + { + "name": "getStatus", + "parameterTypes": [] + }, + { + "name": "getTime", + "parameterTypes": [] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.vo.portal.DashboardStatsResponse", "methods": [ @@ -2537,6 +3450,39 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.vo.portal.PortalAppPluginConfigVo", + "methods": [ + { + "name": "getConfigJson", + "parameterTypes": [] + }, + { + "name": "getDescription", + "parameterTypes": [] + }, + { + "name": "getMeta", + "parameterTypes": [] + }, + { + "name": "getName", + "parameterTypes": [] + }, + { + "name": "getPluginKey", + "parameterTypes": [] + }, + { + "name": "getStatus", + "parameterTypes": [] + }, + { + "name": "getUpdatedAt", + "parameterTypes": [] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.vo.portal.PortalAppResponse", "methods": [ @@ -2661,6 +3607,43 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.model.vo.portal.PortalPluginVo", + "methods": [ + { + "name": "getCreatedAt", + "parameterTypes": [] + }, + { + "name": "getDescription", + "parameterTypes": [] + }, + { + "name": "getId", + "parameterTypes": [] + }, + { + "name": "getIsBuiltin", + "parameterTypes": [] + }, + { + "name": "getIsConnected", + "parameterTypes": [] + }, + { + "name": "getName", + "parameterTypes": [] + }, + { + "name": "getPluginKey", + "parameterTypes": [] + }, + { + "name": "getStatus", + "parameterTypes": [] + } + ] + }, { "type": "dev.qingzhou.pushserver.model.vo.portal.PortalProxyConfigResponse", "methods": [ @@ -2727,6 +3710,33 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.monitor.PluginHeartbeatMonitor", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.grpc.PluginConnectionManager", + "dev.qingzhou.pushserver.service.PluginManagerService" + ] + }, + { + "name": "checkHeartbeats", + "parameterTypes": [] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.plugin.builtin.WebhookPlugin", + "methods": [ + { + "name": "", + "parameterTypes": [ + "com.fasterxml.jackson.databind.ObjectMapper" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.security.CaptchaService", "methods": [ @@ -2764,12 +3774,18 @@ { "type": "dev.qingzhou.pushserver.service.DashboardService" }, + { + "type": "dev.qingzhou.pushserver.service.PluginManagerService" + }, { "type": "dev.qingzhou.pushserver.service.PortalAccessTokenService" }, { "type": "dev.qingzhou.pushserver.service.PortalAppApiKeyService" }, + { + "type": "dev.qingzhou.pushserver.service.PortalAppPluginService" + }, { "type": "dev.qingzhou.pushserver.service.PortalCorpConfigService" }, @@ -2779,6 +3795,9 @@ { "type": "dev.qingzhou.pushserver.service.PortalMessageService" }, + { + "type": "dev.qingzhou.pushserver.service.PortalPluginService" + }, { "type": "dev.qingzhou.pushserver.service.PortalProxyConfigService" }, @@ -2806,6 +3825,27 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.service.impl.PluginManagerServiceImpl", + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.util.List", + "dev.qingzhou.pushserver.grpc.PluginPacketDispatcher", + "dev.qingzhou.push.api.spi.PushSender", + "dev.qingzhou.pushserver.mapper.portal.PortalAppPluginConfigMapper", + "com.fasterxml.jackson.databind.ObjectMapper", + "dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper", + "dev.qingzhou.pushserver.mapper.portal.PortalWecomAppMapper" + ] + }, + { + "name": "init", + "parameterTypes": [] + } + ] + }, { "type": "dev.qingzhou.pushserver.service.impl.PortalAccessTokenServiceImpl", "methods": [ @@ -2834,6 +3874,12 @@ "java.lang.Long" ] }, + { + "name": "removeByAppId", + "parameterTypes": [ + "java.lang.Long" + ] + }, { "name": "requireAppByApiKey", "parameterTypes": [ @@ -2868,6 +3914,45 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.service.impl.PortalAppPluginServiceImpl", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.service.PortalWecomAppService", + "dev.qingzhou.pushserver.service.PluginManagerService", + "dev.qingzhou.pushserver.mapper.portal.PortalAppPluginConfigMapper" + ] + }, + { + "name": "listByApp", + "parameterTypes": [ + "java.lang.Long", + "java.lang.Long" + ] + }, + { + "name": "saveConfig", + "parameterTypes": [ + "java.lang.Long", + "java.lang.Long", + "dev.qingzhou.pushserver.model.dto.portal.AppPluginConfigSaveRequest" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.service.impl.PortalAppPluginServiceImpl$$SpringCGLIB$$0", + "fields": [ + { + "name": "CGLIB$CALLBACK_FILTER" + }, + { + "name": "CGLIB$FACTORY_DATA" + } + ] + }, { "type": "dev.qingzhou.pushserver.service.impl.PortalCorpConfigServiceImpl", "methods": [ @@ -2954,6 +4039,41 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.service.impl.PortalPluginServiceImpl", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.mapper.portal.PortalPluginMapper", + "dev.qingzhou.pushserver.service.PluginManagerService", + "dev.qingzhou.pushserver.grpc.PluginConnectionManager" + ] + }, + { + "name": "listPlugins", + "parameterTypes": [] + }, + { + "name": "switchStatus", + "parameterTypes": [ + "java.lang.Integer", + "java.lang.Integer" + ] + } + ] + }, + { + "type": "dev.qingzhou.pushserver.service.impl.PortalPluginServiceImpl$$SpringCGLIB$$0", + "fields": [ + { + "name": "CGLIB$CALLBACK_FILTER" + }, + { + "name": "CGLIB$FACTORY_DATA" + } + ] + }, { "type": "dev.qingzhou.pushserver.service.impl.PortalProxyConfigServiceImpl", "methods": [ @@ -2963,6 +4083,12 @@ "dev.qingzhou.pushserver.manager.wecom.WecomApiClient" ] }, + { + "name": "deleteByUserId", + "parameterTypes": [ + "java.lang.Long" + ] + }, { "name": "getByUserId", "parameterTypes": [ @@ -3003,6 +4129,13 @@ "parameterTypes": [ "java.lang.String" ] + }, + { + "name": "register", + "parameterTypes": [ + "java.lang.String", + "java.lang.String" + ] } ] }, @@ -3056,6 +4189,16 @@ "java.lang.Long", "java.lang.Long" ] + }, + { + "name": "updateApp", + "parameterTypes": [ + "java.lang.Long", + "java.lang.Long", + "java.lang.String", + "java.lang.String", + "java.lang.String" + ] } ] }, @@ -3081,6 +4224,17 @@ } ] }, + { + "type": "dev.qingzhou.pushserver.service.impl.ServerPushSender", + "methods": [ + { + "name": "", + "parameterTypes": [ + "dev.qingzhou.pushserver.service.PushService" + ] + } + ] + }, { "type": "dev.qingzhou.pushserver.service.impl.SystemConfigServiceImpl", "methods": [ @@ -3097,9 +4251,25 @@ "java.lang.String" ] }, + { + "name": "getTurnstileSecretKey", + "parameterTypes": [] + }, + { + "name": "getTurnstileSiteKey", + "parameterTypes": [] + }, { "name": "isTurnstileEnabled", "parameterTypes": [] + }, + { + "name": "setTurnstileConfig", + "parameterTypes": [ + "boolean", + "java.lang.String", + "java.lang.String" + ] } ] }, @@ -3120,6 +4290,114 @@ { "type": "int[]" }, + { + "type": "io.grpc.BindableService" + }, + { + "type": "io.grpc.ServerInterceptor" + }, + { + "type": "io.grpc.census.InternalCensusStatsAccessor" + }, + { + "type": "io.grpc.census.InternalCensusTracingAccessor" + }, + { + "type": "io.grpc.netty.shaded.io.grpc.netty.NettyServerProvider" + }, + { + "type": "io.grpc.netty.shaded.io.netty.bootstrap.ServerBootstrap$1" + }, + { + "type": "io.grpc.netty.shaded.io.netty.bootstrap.ServerBootstrap$ServerBootstrapAcceptor" + }, + { + "type": "io.grpc.netty.shaded.io.netty.buffer.AbstractByteBufAllocator" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.AbstractChannelHandlerContext" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.ChannelOutboundBuffer" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.DefaultChannelConfig" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.DefaultChannelPipeline" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.DefaultChannelPipeline$HeadContext" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.DefaultChannelPipeline$TailContext" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.DefaultFileRegion" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.epoll.Epoll", + "methods": [ + { + "name": "isAvailable", + "parameterTypes": [] + }, + { + "name": "unavailabilityCause", + "parameterTypes": [] + } + ] + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.epoll.NativeDatagramPacketArray$NativeDatagramPacket" + }, + { + "type": "io.grpc.netty.shaded.io.netty.channel.unix.PeerCredentials" + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.AbstractReferenceCounted", + "fields": [ + { + "name": "refCnt" + } + ] + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.DefaultAttributeMap" + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.concurrent.DefaultPromise" + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.concurrent.SingleThreadEventExecutor" + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.internal.shaded.org.jctools.queues.BaseMpscLinkedArrayQueueColdProducerFields", + "fields": [ + { + "name": "producerLimit" + } + ] + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.internal.shaded.org.jctools.queues.BaseMpscLinkedArrayQueueConsumerFields", + "fields": [ + { + "name": "consumerIndex" + } + ] + }, + { + "type": "io.grpc.netty.shaded.io.netty.util.internal.shaded.org.jctools.queues.BaseMpscLinkedArrayQueueProducerFields", + "fields": [ + { + "name": "producerIndex" + } + ] + }, + { + "type": "io.grpc.okhttp.OkHttpServerProvider" + }, { "type": "io.micrometer.context.ContextSnapshot" }, @@ -3340,6 +4618,20 @@ { "type": "java.io.Closeable" }, + { + "type": "java.io.FileDescriptor" + }, + { + "type": "java.io.FileNotFoundException", + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, { "type": "java.io.Serializable" }, @@ -3349,6 +4641,9 @@ { "type": "java.lang.AutoCloseable" }, + { + "type": "java.lang.BaseVirtualThread" + }, { "type": "java.lang.Boolean", "jniAccessible": true, @@ -3400,6 +4695,9 @@ { "type": "java.lang.Error" }, + { + "type": "java.lang.Iterable" + }, { "type": "java.lang.Long" }, @@ -3427,6 +4725,22 @@ { "type": "java.lang.Object[]" }, + { + "type": "java.lang.ProcessHandle", + "methods": [ + { + "name": "current", + "parameterTypes": [] + }, + { + "name": "pid", + "parameterTypes": [] + } + ] + }, + { + "type": "java.lang.Record" + }, { "type": "java.lang.RuntimeException" }, @@ -3440,7 +4754,13 @@ "type": "java.lang.System" }, { - "type": "java.lang.Thread" + "type": "java.lang.Thread", + "methods": [ + { + "name": "isVirtual", + "parameterTypes": [] + } + ] }, { "type": "java.lang.Thread$Builder" @@ -3559,12 +4879,41 @@ { "type": "java.net.http.HttpClient" }, + { + "type": "java.nio.Bits" + }, + { + "type": "java.nio.Buffer", + "fields": [ + { + "name": "address" + } + ] + }, + { + "type": "java.nio.ByteBuffer" + }, + { + "type": "java.nio.DirectByteBuffer" + }, + { + "type": "java.nio.channels.FileChannel" + }, + { + "type": "java.nio.channels.spi.SelectorProvider" + }, { "type": "java.security.AlgorithmParametersSpi" }, { "type": "java.security.KeyStoreSpi" }, + { + "type": "java.security.interfaces.ECPrivateKey" + }, + { + "type": "java.security.interfaces.ECPublicKey" + }, { "type": "java.security.interfaces.RSAPrivateKey" }, @@ -3595,6 +4944,15 @@ { "type": "java.text.ListFormat" }, + { + "type": "java.time.Instant" + }, + { + "type": "java.util.AbstractCollection" + }, + { + "type": "java.util.AbstractList" + }, { "type": "java.util.AbstractMap" }, @@ -3607,6 +4965,12 @@ } ] }, + { + "type": "java.util.Collection" + }, + { + "type": "java.util.Collections$EmptyList" + }, { "type": "java.util.Enumeration" }, @@ -3616,9 +4980,18 @@ { "type": "java.util.HashSet" }, + { + "type": "java.util.ImmutableCollections$AbstractImmutableCollection" + }, + { + "type": "java.util.ImmutableCollections$AbstractImmutableList" + }, { "type": "java.util.ImmutableCollections$AbstractImmutableMap" }, + { + "type": "java.util.ImmutableCollections$ListN" + }, { "type": "java.util.ImmutableCollections$Map1" }, @@ -3640,6 +5013,9 @@ { "type": "java.util.MapCustomizer" }, + { + "type": "java.util.RandomAccess" + }, { "type": "java.util.SequencedCollection" }, @@ -3652,9 +5028,21 @@ { "type": "java.util.concurrent.Executor" }, + { + "type": "java.util.concurrent.ScheduledExecutorService" + }, { "type": "java.util.concurrent.ThreadFactory" }, + { + "type": "java.util.concurrent.atomic.LongAdder", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "java.util.logging.LogManager" }, @@ -3701,7 +5089,13 @@ "type": "jdk.internal.loader.ClassLoaders$PlatformClassLoader" }, { - "type": "jdk.internal.misc.Unsafe" + "type": "jdk.internal.misc.Unsafe", + "methods": [ + { + "name": "getUnsafe", + "parameterTypes": [] + } + ] }, { "type": "kotlin.Metadata" @@ -3733,6 +5127,12 @@ { "type": "oracle.ucp.jdbc.PoolDataSourceImpl" }, + { + "type": "org.aopalliance.aop.Advice" + }, + { + "type": "org.aopalliance.intercept.Interceptor" + }, { "type": "org.aopalliance.intercept.MethodInterceptor" }, @@ -3853,6 +5253,12 @@ { "type": "org.apache.ibatis.annotations.Mapper" }, + { + "type": "org.apache.ibatis.binding.MapperProxy", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, { "type": "org.apache.ibatis.executor.Executor", "methods": [ @@ -3925,6 +5331,23 @@ } ] }, + { + "type": "org.apache.ibatis.mapping.BoundSql", + "fields": [ + { + "name": "parameterMappings" + }, + { + "name": "sql" + } + ], + "methods": [ + { + "name": "getAdditionalParameters", + "parameterTypes": [] + } + ] + }, { "type": "org.apache.ibatis.ognl.OgnlRuntime$ClassPropertyMethodCache" }, @@ -3967,6 +5390,13 @@ { "type": "org.apache.ibatis.session.SqlSession", "methods": [ + { + "name": "delete", + "parameterTypes": [ + "java.lang.String", + "java.lang.Object" + ] + }, { "name": "insert", "parameterTypes": [ @@ -4285,6 +5715,9 @@ { "type": "org.slf4j.spi.LocationAwareLogger" }, + { + "type": "org.springframework.aop.Advisor" + }, { "type": "org.springframework.aop.PointcutAdvisor" }, @@ -4362,6 +5795,9 @@ { "type": "org.springframework.aop.support.AbstractPointcutAdvisor" }, + { + "type": "org.springframework.aot.generate.Generated" + }, { "type": "org.springframework.aot.hint.annotation.Reflective" }, @@ -4422,6 +5858,9 @@ { "type": "org.springframework.beans.factory.config.BeanPostProcessor" }, + { + "type": "org.springframework.beans.factory.config.DestructionAwareBeanPostProcessor" + }, { "type": "org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor" }, @@ -4457,6 +5896,9 @@ } ] }, + { + "type": "org.springframework.boot.LazyInitializationExcludeFilter" + }, { "type": "org.springframework.boot.SpringBootConfiguration" }, @@ -4873,6 +6315,9 @@ { "type": "org.springframework.boot.autoconfigure.ssl.SslPropertiesBundleRegistrar" }, + { + "type": "org.springframework.boot.autoconfigure.task.ScheduledBeanLazyInitializationExcludeFilter" + }, { "type": "org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration", "methods": [ @@ -4989,6 +6434,10 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "scheduledBeanLazyInitializationExcludeFilter", + "parameterTypes": [] } ] }, @@ -5004,14 +6453,26 @@ ] }, { - "name": "simpleAsyncTaskSchedulerBuilder", - "parameterTypes": [] + "name": "simpleAsyncTaskSchedulerBuilder", + "parameterTypes": [] + } + ] + }, + { + "type": "org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$TaskSchedulerConfiguration", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "taskScheduler", + "parameterTypes": [ + "org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder" + ] } ] }, - { - "type": "org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$TaskSchedulerConfiguration" - }, { "type": "org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$ThreadPoolTaskSchedulerBuilderConfiguration", "methods": [ @@ -5335,7 +6796,31 @@ "type": "org.springframework.boot.http.converter.autoconfigure.DefaultServerHttpMessageConvertersCustomizer" }, { - "type": "org.springframework.boot.http.converter.autoconfigure.GsonHttpMessageConvertersConfiguration" + "type": "org.springframework.boot.http.converter.autoconfigure.GsonHttpMessageConvertersConfiguration", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "org.springframework.boot.http.converter.autoconfigure.GsonHttpMessageConvertersConfiguration$JacksonAndJsonbUnavailableCondition", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "org.springframework.boot.http.converter.autoconfigure.GsonHttpMessageConvertersConfiguration$PreferGsonOrJacksonAndJsonbUnavailableCondition", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { "type": "org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration", @@ -5405,6 +6890,9 @@ } ] }, + { + "type": "org.springframework.boot.http.converter.autoconfigure.Jackson2HttpMessageConvertersConfiguration$Jackson2JsonMessageConvertersCustomizer" + }, { "type": "org.springframework.boot.http.converter.autoconfigure.Jackson2HttpMessageConvertersConfiguration$PreferJackson2OrJacksonUnavailableCondition", "methods": [ @@ -7129,9 +8617,37 @@ { "type": "org.springframework.scheduling.SchedulingTaskExecutor" }, + { + "type": "org.springframework.scheduling.TaskScheduler" + }, { "type": "org.springframework.scheduling.annotation.AsyncConfigurer" }, + { + "type": "org.springframework.scheduling.annotation.EnableScheduling" + }, + { + "type": "org.springframework.scheduling.annotation.Scheduled" + }, + { + "type": "org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor" + }, + { + "type": "org.springframework.scheduling.annotation.Schedules" + }, + { + "type": "org.springframework.scheduling.annotation.SchedulingConfiguration", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "scheduledAnnotationProcessor", + "parameterTypes": [] + } + ] + }, { "type": "org.springframework.scheduling.concurrent.CustomizableThreadFactory" }, @@ -7144,6 +8660,9 @@ { "type": "org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler" }, + { + "type": "org.springframework.scheduling.config.ScheduledTaskHolder" + }, { "type": "org.springframework.security.access.SecurityConfig" }, @@ -8426,6 +9945,25 @@ { "name": "theUnsafe" } + ], + "methods": [ + { + "name": "invokeCleaner", + "parameterTypes": [ + "java.nio.ByteBuffer" + ] + } + ] + }, + { + "type": "sun.nio.ch.SelectorImpl", + "fields": [ + { + "name": "publicSelectedKeys" + }, + { + "name": "selectedKeys" + } ] }, { @@ -8861,6 +10399,13 @@ ] } }, + { + "type": { + "proxy": [ + "dev.qingzhou.pushserver.mapper.portal.PortalAppPluginConfigMapper" + ] + } + }, { "type": { "proxy": [ @@ -8875,6 +10420,27 @@ ] } }, + { + "type": { + "proxy": [ + "dev.qingzhou.pushserver.mapper.portal.PortalPluginActionLogMapper" + ] + } + }, + { + "type": { + "proxy": [ + "dev.qingzhou.pushserver.mapper.portal.PortalPluginHeartbeatLogMapper" + ] + } + }, + { + "type": { + "proxy": [ + "dev.qingzhou.pushserver.mapper.portal.PortalPluginMapper" + ] + } + }, { "type": { "proxy": [ @@ -9007,6 +10573,70 @@ ] } }, + { + "type": { + "lambda": { + "declaringClass": "dev.qingzhou.pushserver.controller.PortalPluginLogController", + "interfaces": [ + "com.baomidou.mybatisplus.core.toolkit.support.SFunction" + ] + } + }, + "methods": [ + { + "name": "writeReplace", + "parameterTypes": [] + } + ] + }, + { + "type": { + "lambda": { + "declaringClass": "dev.qingzhou.pushserver.service.impl.PluginManagerServiceImpl", + "interfaces": [ + "com.baomidou.mybatisplus.core.toolkit.support.SFunction" + ] + } + }, + "methods": [ + { + "name": "writeReplace", + "parameterTypes": [] + } + ] + }, + { + "type": { + "lambda": { + "declaringClass": "dev.qingzhou.pushserver.service.impl.PortalAppPluginServiceImpl", + "interfaces": [ + "com.baomidou.mybatisplus.core.toolkit.support.SFunction" + ] + } + }, + "methods": [ + { + "name": "writeReplace", + "parameterTypes": [] + } + ] + }, + { + "type": { + "lambda": { + "declaringClass": "dev.qingzhou.pushserver.service.impl.PortalPluginServiceImpl", + "interfaces": [ + "com.baomidou.mybatisplus.core.toolkit.support.SFunction" + ] + } + }, + "methods": [ + { + "name": "writeReplace", + "parameterTypes": [] + } + ] + }, { "type": { "lambda": { @@ -9052,12 +10682,24 @@ { "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalAppApiKeyMapper.xml" }, + { + "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalAppPluginConfigMapper.xml" + }, { "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalCorpConfigMapper.xml" }, { "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalMessageLogMapper.xml" }, + { + "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalPluginActionLogMapper.xml" + }, + { + "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalPluginHeartbeatLogMapper.xml" + }, + { + "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalPluginMapper.xml" + }, { "glob": "/dev/qingzhou/pushserver/mapper/portal/PortalProxyConfigMapper.xml" }, @@ -9082,6 +10724,9 @@ { "glob": "META-INF/services/com.baomidou.mybatisplus.core.spi.CompatibleSet" }, + { + "glob": "META-INF/services/io.grpc.ServerProvider" + }, { "glob": "META-INF/services/jakarta.el.ExpressionFactory" }, @@ -9259,12 +10904,27 @@ { "glob": "data.sql" }, + { + "glob": "dev/qingzhou/push/api/grpc/PluginGatewayGrpc$AsyncService.class" + }, + { + "glob": "dev/qingzhou/push/api/grpc/PluginGatewayGrpc$PluginGatewayImplBase.class" + }, + { + "glob": "dev/qingzhou/push/api/spi/PushPlugin.class" + }, + { + "glob": "dev/qingzhou/push/api/spi/PushSender.class" + }, { "glob": "dev/qingzhou/pushserver" }, { "glob": "dev/qingzhou/pushserver/aspect/SecurityInterceptor.class" }, + { + "glob": "dev/qingzhou/pushserver/config/GrpcServerConfig.class" + }, { "glob": "dev/qingzhou/pushserver/config/JsonDtoPackageHints$DtoHints.class" }, @@ -9304,6 +10964,9 @@ { "glob": "dev/qingzhou/pushserver/config/PushConfiguration.class" }, + { + "glob": "dev/qingzhou/pushserver/config/SchedulingConfig.class" + }, { "glob": "dev/qingzhou/pushserver/config/WebConfig.class" }, @@ -9322,6 +10985,9 @@ { "glob": "dev/qingzhou/pushserver/controller/PortalAppController.class" }, + { + "glob": "dev/qingzhou/pushserver/controller/PortalAppPluginController.class" + }, { "glob": "dev/qingzhou/pushserver/controller/PortalAuthController.class" }, @@ -9340,6 +11006,12 @@ { "glob": "dev/qingzhou/pushserver/controller/PortalMessageController.class" }, + { + "glob": "dev/qingzhou/pushserver/controller/PortalPluginController.class" + }, + { + "glob": "dev/qingzhou/pushserver/controller/PortalPluginLogController.class" + }, { "glob": "dev/qingzhou/pushserver/controller/PortalProxyController.class" }, @@ -9361,18 +11033,45 @@ { "glob": "dev/qingzhou/pushserver/exception/PortalExceptionHandler.class" }, + { + "glob": "dev/qingzhou/pushserver/grpc/GrpcAdapter.class" + }, + { + "glob": "dev/qingzhou/pushserver/grpc/PluginAuthInterceptor.class" + }, + { + "glob": "dev/qingzhou/pushserver/grpc/PluginConnectionManager.class" + }, + { + "glob": "dev/qingzhou/pushserver/grpc/PluginGatewayImpl.class" + }, + { + "glob": "dev/qingzhou/pushserver/grpc/PluginPacketDispatcher.class" + }, { "glob": "dev/qingzhou/pushserver/manager/wecom/WecomApiClient.class" }, { "glob": "dev/qingzhou/pushserver/mapper/portal/PortalAppApiKeyMapper.xml" }, + { + "glob": "dev/qingzhou/pushserver/mapper/portal/PortalAppPluginConfigMapper.xml" + }, { "glob": "dev/qingzhou/pushserver/mapper/portal/PortalCorpConfigMapper.xml" }, { "glob": "dev/qingzhou/pushserver/mapper/portal/PortalMessageLogMapper.xml" }, + { + "glob": "dev/qingzhou/pushserver/mapper/portal/PortalPluginActionLogMapper.xml" + }, + { + "glob": "dev/qingzhou/pushserver/mapper/portal/PortalPluginHeartbeatLogMapper.xml" + }, + { + "glob": "dev/qingzhou/pushserver/mapper/portal/PortalPluginMapper.xml" + }, { "glob": "dev/qingzhou/pushserver/mapper/portal/PortalProxyConfigMapper.xml" }, @@ -9385,6 +11084,12 @@ { "glob": "dev/qingzhou/pushserver/mapper/portal/PortalWecomAppMapper.xml" }, + { + "glob": "dev/qingzhou/pushserver/monitor/PluginHeartbeatMonitor.class" + }, + { + "glob": "dev/qingzhou/pushserver/plugin/builtin/WebhookPlugin.class" + }, { "glob": "dev/qingzhou/pushserver/security/CaptchaService.class" }, @@ -9400,12 +11105,18 @@ { "glob": "dev/qingzhou/pushserver/service/DashboardService.class" }, + { + "glob": "dev/qingzhou/pushserver/service/PluginManagerService.class" + }, { "glob": "dev/qingzhou/pushserver/service/PortalAccessTokenService.class" }, { "glob": "dev/qingzhou/pushserver/service/PortalAppApiKeyService.class" }, + { + "glob": "dev/qingzhou/pushserver/service/PortalAppPluginService.class" + }, { "glob": "dev/qingzhou/pushserver/service/PortalCorpConfigService.class" }, @@ -9415,6 +11126,9 @@ { "glob": "dev/qingzhou/pushserver/service/PortalMessageService.class" }, + { + "glob": "dev/qingzhou/pushserver/service/PortalPluginService.class" + }, { "glob": "dev/qingzhou/pushserver/service/PortalProxyConfigService.class" }, @@ -9433,6 +11147,9 @@ { "glob": "dev/qingzhou/pushserver/service/impl/DashboardServiceImpl.class" }, + { + "glob": "dev/qingzhou/pushserver/service/impl/PluginManagerServiceImpl.class" + }, { "glob": "dev/qingzhou/pushserver/service/impl/PortalAccessTokenServiceImpl$CachedToken.class" }, @@ -9442,6 +11159,9 @@ { "glob": "dev/qingzhou/pushserver/service/impl/PortalAppApiKeyServiceImpl.class" }, + { + "glob": "dev/qingzhou/pushserver/service/impl/PortalAppPluginServiceImpl.class" + }, { "glob": "dev/qingzhou/pushserver/service/impl/PortalCorpConfigServiceImpl.class" }, @@ -9451,6 +11171,9 @@ { "glob": "dev/qingzhou/pushserver/service/impl/PortalMessageServiceImpl.class" }, + { + "glob": "dev/qingzhou/pushserver/service/impl/PortalPluginServiceImpl.class" + }, { "glob": "dev/qingzhou/pushserver/service/impl/PortalProxyConfigServiceImpl.class" }, @@ -9463,12 +11186,21 @@ { "glob": "dev/qingzhou/pushserver/service/impl/PushServiceImpl.class" }, + { + "glob": "dev/qingzhou/pushserver/service/impl/ServerPushSender.class" + }, { "glob": "dev/qingzhou/pushserver/service/impl/SystemConfigServiceImpl.class" }, { "glob": "git.properties" }, + { + "glob": "io/grpc/BindableService.class" + }, + { + "glob": "io/grpc/ServerInterceptor.class" + }, { "glob": "jakarta/servlet/LocalStrings.properties" }, @@ -10165,6 +11897,33 @@ { "glob": "org/springframework/boot/gson/autoconfigure/GsonAutoConfiguration.class" }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$GsonHttpConvertersCustomizer.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$GsonHttpMessageConverterConfiguration.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$JacksonAndJsonbUnavailableCondition$Jackson2Available.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$JacksonAndJsonbUnavailableCondition$JacksonAvailable.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$JacksonAndJsonbUnavailableCondition$JsonbPreferred.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$JacksonAndJsonbUnavailableCondition.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$PreferGsonOrJacksonAndJsonbUnavailableCondition$GsonPreferred.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$PreferGsonOrJacksonAndJsonbUnavailableCondition$JacksonJsonbUnavailable.class" + }, + { + "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration$PreferGsonOrJacksonAndJsonbUnavailableCondition.class" + }, { "glob": "org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.class" }, @@ -10621,6 +12380,12 @@ { "glob": "org/springframework/core/annotation/Order.class" }, + { + "glob": "org/springframework/scheduling/annotation/EnableScheduling.class" + }, + { + "glob": "org/springframework/scheduling/annotation/SchedulingConfiguration.class" + }, { "glob": "org/springframework/security/config/annotation/authentication/configuration/AuthenticationConfiguration$AuthenticationManagerDelegator.class" }, @@ -10748,22 +12513,61 @@ "glob": "static/.well-known/appspecific/com.chrome.devtools.json" }, { - "glob": "static/assets/DashboardView-BKGHLnEP.js" + "glob": "static/assets/DashboardView-B2MNrj-2.js" + }, + { + "glob": "static/assets/DashboardView-BQ-3UbUb.js" + }, + { + "glob": "static/assets/DashboardView-CDau1uW5.js" + }, + { + "glob": "static/assets/DashboardView-D6Hck1UZ.js" }, { "glob": "static/assets/DashboardView-DHdOv7sv.css" }, + { + "glob": "static/assets/InitView-Bab0GDZm.css" + }, + { + "glob": "static/assets/InitView-Dn7DLcnb.js" + }, + { + "glob": "static/assets/PluginsView-CNi8y0aY.css" + }, + { + "glob": "static/assets/PluginsView-CeF5nKD9.css" + }, + { + "glob": "static/assets/PluginsView-CssPFoQP.js" + }, + { + "glob": "static/assets/PluginsView-DhuTfWVL.js" + }, { "glob": "static/assets/ProxyView-BNsKsUAn.css" }, { - "glob": "static/assets/ProxyView-CdQhhSdP.js" + "glob": "static/assets/ProxyView-DRIg8mIw.js" + }, + { + "glob": "static/assets/index-BHUmIbXc.js" + }, + { + "glob": "static/assets/index-BQHYf_DI.js" + }, + { + "glob": "static/assets/index-ChEtmHeK.js" + }, + { + "glob": "static/assets/index-DfoQW2_6.js" }, { - "glob": "static/assets/index-C9wU1arO.js" + "glob": "static/assets/index-FpXCWsKn.css" }, { - "glob": "static/assets/index-sMwk8kPl.css" + "glob": "static/assets/index-OuPPjUqQ.css" }, { "glob": "static/favicon.png" diff --git a/src/main/resources/META-INF/native-image/reflect-config.json b/push-server-core/src/main/resources/META-INF/native-image/reflect-config.json similarity index 100% rename from src/main/resources/META-INF/native-image/reflect-config.json rename to push-server-core/src/main/resources/META-INF/native-image/reflect-config.json diff --git a/src/main/resources/application-dev.yml b/push-server-core/src/main/resources/application-dev.yml similarity index 100% rename from src/main/resources/application-dev.yml rename to push-server-core/src/main/resources/application-dev.yml diff --git a/src/main/resources/application-prod.yml b/push-server-core/src/main/resources/application-prod.yml similarity index 100% rename from src/main/resources/application-prod.yml rename to push-server-core/src/main/resources/application-prod.yml diff --git a/src/main/resources/application.yml b/push-server-core/src/main/resources/application.yml similarity index 100% rename from src/main/resources/application.yml rename to push-server-core/src/main/resources/application.yml diff --git a/src/test/java/dev/qingzhou/pushserver/PushServerApplicationTests.java b/push-server-core/src/test/java/dev/qingzhou/pushserver/PushServerApplicationTests.java similarity index 100% rename from src/test/java/dev/qingzhou/pushserver/PushServerApplicationTests.java rename to push-server-core/src/test/java/dev/qingzhou/pushserver/PushServerApplicationTests.java diff --git a/push-server-core/src/test/java/dev/qingzhou/pushserver/service/DashboardServiceTest.java b/push-server-core/src/test/java/dev/qingzhou/pushserver/service/DashboardServiceTest.java new file mode 100644 index 0000000..8166cfe --- /dev/null +++ b/push-server-core/src/test/java/dev/qingzhou/pushserver/service/DashboardServiceTest.java @@ -0,0 +1,43 @@ +package dev.qingzhou.pushserver.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import dev.qingzhou.pushserver.model.vo.portal.DashboardStatsResponse; +import dev.qingzhou.pushserver.service.impl.DashboardServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class DashboardServiceTest { + + private DashboardService dashboardService; + + @Mock + private PortalMessageLogService messageLogService; + + @Mock + private PortalWecomAppService appService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + dashboardService = new DashboardServiceImpl(messageLogService, appService); + } + + @Test + void testFetchStatsWithNoLogs() { + Long userId = 1L; + when(messageLogService.count(any(Wrapper.class))).thenReturn(0L); + when(appService.count(any(Wrapper.class))).thenReturn(0L); + when(messageLogService.getOne(any(Wrapper.class), any(Boolean.class))).thenReturn(null); + + DashboardStatsResponse stats = dashboardService.fetchStats(userId); + + assertEquals(0L, stats.getTodayTotal()); + assertEquals(100.0, stats.getSuccessRate(), "Success rate should be 100% when no logs exist"); + } +}