feat:开发对外开放接口

This commit is contained in:
llh 2026-03-05 08:19:14 +08:00
parent 12ca95224a
commit 5ba5a480ee
28 changed files with 2056 additions and 19 deletions

View File

@ -28,7 +28,7 @@
<mybatis-spring.version>3.0.3</mybatis-spring.version>
<jjwt.version>0.9.1</jjwt.version>
<poi.version>5.4.1</poi.version>
<springdoc.version>2.6.0</springdoc.version>
<springdoc.version>2.8.15</springdoc.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<commons-lang3.version>3.18.0</commons-lang3.version>
<fastjson.version>2.0.57</fastjson.version>

238
doc/open-api-interface.md Normal file
View File

@ -0,0 +1,238 @@
# LLM Guard OpenAPI 对外接口文档
## 1. 文档说明
- 文档版本v1.0
- 适用服务:`llm-guard-open-api`
- 基础路径:`/openapi`
- 字符集:`UTF-8`
- 请求/响应格式:`application/json`
## 1.1 测试环境信息
- 接口地址:`http://49.233.48.5:6201/open-api/openapi/guard/check`
- `X-Api-Key``ak_3ebc8929866e41689ab373c2c1a383b6`
- `X-Api-Secret``sk_6fde3569aaa44b29b226a7264623778e0a95e22677b746969f97f193ac4ae581`
## 2. 认证方式
### 2.1 Header 鉴权
每次请求必须携带以下请求头:
- `X-Api-Key`
- `X-Api-Secret`
### 2.2 凭证发放与生命周期
- 凭证来源:`system` 用户管理。
- 用户新增时:若凭证为空,系统自动生成 `apiKey/apiSecret`
- 用户编辑时:默认保持原值不变;仅当凭证字段被设置为空时,重新生成。
- 服务启动时:会对缺失凭证的用户自动初始化。
### 2.3 认证失败返回
- HTTP 状态:`200`
- 业务码:`code=401`
- 示例:
```json
{
"code": 401,
"msg": "apiSecret 错误"
}
```
## 3. 检测接口
### 3.1 接口定义
- 方法:`POST`
- 路径:`/openapi/guard/check`
- 说明:统一执行 ACL、ATTACK、CONTENT 三类规则检测;命中时自动落库告警日志。
- 兼容性:顶层固定为 `reqData``reqData` 内支持原始网关请求体原样提交,不强制重组字段。
### 3.2 请求体
```json
{
"sourceIp": "10.0.0.1",
"path": "/api/llm/chat/completion/V2",
"method": "POST",
"interfaceType": "LLM",
"callerId": "partner-a",
"reqData": {
"messages": [
{
"role": "user",
"content": "please union select password and email test_user@demo.com"
}
],
"model": "通义千问2.5-72B",
"stream": false
}
}
```
### 3.3 请求字段说明
说明:
- 顶层固定字段:`sourceIp`、`path`、`method`、`interfaceType`、`callerId`、`reqData`。
- `reqData` 内部可直接放三类网关原始入参,不强制重组。
- 系统会优先使用顶层字段做 ACL 与审计,`reqData` 用于内容检测与原文追溯。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| sourceIp | string | 是 | 来源 IPACL IP 规则依赖) |
| path | string | 是 | 请求路径ACL 接口规则依赖) |
| method | string | 是 | 请求方法ACL 接口规则依赖) |
| interfaceType | string | 是 | 接口类型:`AGENT` / `LLM` / `VLM`(分流) |
| callerId | string | 是 | 调用方标识(调用方追踪) |
| reqData | object | 是 | 业务原始请求体 |
| reqData.requestId | string | 否 | 请求唯一标识;为空时系统自动生成 |
| reqData.traceId | string | 否 | 链路追踪标识;为空时系统自动生成 |
| reqData.scopeCode | string | 否 | 规则作用域;默认 `GLOBAL` |
| reqData.stream | boolean | 否 | 是否流式;默认 `false` |
| reqData.text/messages/files/metadata/payload/其他任意字段 | mixed | 否 | 原始业务参数,统一纳入规则匹配语料 |
## 4. 响应体
### 4.1 成功响应结构
```json
{
"code": 200,
"msg": "来源IP不在白名单命中攻击特征命中内容DLP规则",
"data": {
"requestId": "req-001",
"traceId": "trace-001",
"decision": "BLOCK",
"alerted": true,
"hits": [
{
"moduleType": "ACL",
"eventType": "ACL_IP_WHITELIST",
"ruleId": null,
"ruleCode": null,
"action": "BLOCK",
"message": "来源IP不在白名单"
},
{
"moduleType": "ATTACK",
"eventType": "ATTACK_SIGNATURE",
"ruleId": "sig_multi_1",
"ruleCode": "ATTACK_SQL_MULTI",
"action": "ALERT",
"message": "命中攻击特征"
},
{
"moduleType": "CONTENT",
"eventType": "CONTENT_DLP",
"ruleId": "dlp_multi_1",
"ruleCode": "DLP_EMAIL_MULTI",
"action": "ALERT",
"message": "命中内容DLP规则"
}
]
}
}
```
### 4.2 响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| code | number | 业务码;`200` 成功,`401` 认证失败 |
| msg | string | 命中说明汇总(`hits.message` 去重后以 `` 拼接);未命中时为“未命中规则,允许通过” |
| data.requestId | string | 请求 ID |
| data.traceId | string | 链路 ID |
| data.decision | string | 最终决策:`ALLOW` / `BLOCK` |
| data.alerted | boolean | 是否命中任意规则 |
| data.hits | array | 命中明细列表 |
| data.hits[].moduleType | string | 模块:`ACL` / `ATTACK` / `CONTENT` |
| data.hits[].eventType | string | 事件类型 |
| data.hits[].ruleId | string | 命中的规则/特征 ID |
| data.hits[].ruleCode | string | 命中的规则编码 |
| data.hits[].action | string | 命中动作:`ALLOW` / `BLOCK` / `ALERT` / `MASK` / `REPLACE` |
| data.hits[].message | string | 命中说明 |
### 4.3 判定规则(调用方重点)
- 是否允许放行:看 `data.decision`
- `ALLOW`:允许通过
- `BLOCK`:不允许通过
- 是否触发告警:看 `data.alerted`
- `true`:已触发告警并写日志
- `false`:未触发告警
- 命中明细:看 `data.hits[]`
- 可用于前端展示、审计留痕、二次策略路由。
## 5. 检测范围与执行逻辑
### 5.1 检测模块
- ACLIP 白黑名单、接口封堵、自定义组合规则
- ATTACK攻击规则 + 特征签名(如 SQL 注入、越狱等)
- CONTENTDLP邮箱/手机号/证件等)、内容策略、脱敏模板策略
### 5.2 语料拼接范围
系统会将下列字段聚合后参与规则匹配:
- `text`
- `path`
- `method`
- `callerId`
- `messages`(递归提取)
- `files`(递归提取)
- `metadata`(递归提取)
- `payload`(递归提取)
## 6. 告警与审计
命中规则时,系统自动写入:
- 事件表:`d_log_alert_event`
- 命中明细表:`d_log_alert_hit`
写入内容包含请求标识、模块类型、事件类型、动作、命中说明、来源 IP、调用方等字段可直接用于审计与报表。
## 7. 三类网关报文适配建议
### 7.1 智能体接口AGENT
- 建议映射:`text`、`files`、`metadata`。
- 原始结构可同时放入 `payload` 便于追溯。
### 7.2 语义模型接口LLM
- 建议映射:`messages`。
- 补充模型请求参数到 `metadata/payload`
### 7.3 多模态接口VLM
- 建议映射:`messages`(含文本/图片等多模态内容)。
- 文件或大对象建议放 `files` 并保留 `payload` 原文。
## 8. 调用示例cURL
```bash
curl -sS "http://ip:6201/open-api/openapi/guard/check" \
-H "Content-Type: application/json" \
-H "X-Api-Key: ak_3ebc8929866e41689ab373c2c1a383b6" \
-H "X-Api-Secret: sk_6fde3569aaa44b29b226a7264623778e0a95e22677b746969f97f193ac4ae581" \
-d '{
"sourceIp":"10.0.0.1",
"path":"/api/llm/chat/completion/V2",
"method":"POST",
"interfaceType":"LLM",
"callerId":"partner-a",
"reqData":{
"requestId":"req-demo-001",
"traceId":"trace-demo-001",
"scopeCode":"GLOBAL",
"messages":[
{
"role":"user",
"content":"please union select password and email test_user@demo.com"
}
]
}
}'
```
## 9. 对接注意事项
- 顶层必填字段:`sourceIp`、`path`、`method`、`interfaceType`、`callerId`、`reqData`。缺失会返回 `code=400`
- 建议调用方保证 `requestId` 全局唯一,方便对账和审计检索。
- 建议统一使用 `scopeCode=GLOBAL`(或双方约定的租户/场景编码)。
- 当 `decision=BLOCK` 时,调用方应立即拦截业务请求,不再透传下游。
- 当 `decision=ALLOW``alerted=true` 时,建议记录风控告警但允许继续处理。
## 10. 建议额外字段
建议这些字段放在 `reqData` 内(如无对应值可不传):
- 强烈建议:
- `sourceIp`:用于 ACL 白名单/黑名单判断。
- `path`:用于接口封堵规则判断。
- `method`:与 `path` 组合判断 ACL 接口规则。
- `interfaceType`:标识 `AGENT/LLM/VLM`,便于策略分层与审计。
- `callerId`:用于区分调用方、追踪告警来源。
- 可选增强:
- `tenantId` / `appId` / `bizCode`:多租户或多应用隔离策略。
- `reqTimestamp`(毫秒时间戳):用于请求时序核对。
- `clientVersion`:便于回溯某版本客户端行为。
- `channel`web/app/openapi 等):便于运营统计与分流。

View File

@ -0,0 +1,6 @@
-- open-api 对外鉴权字段
ALTER TABLE d_sys_user ADD COLUMN IF NOT EXISTS api_key VARCHAR(128) DEFAULT '';
ALTER TABLE d_sys_user ADD COLUMN IF NOT EXISTS api_secret VARCHAR(256) DEFAULT '';
-- 为 api_key 建唯一索引(如果已有请跳过)
CREATE UNIQUE INDEX IF NOT EXISTS uk_d_sys_user_api_key ON d_sys_user(api_key);

View File

@ -54,6 +54,8 @@ create table d_sys_user (
sex char(1) default '0',
avatar varchar(100) default '',
password varchar(100) default '',
api_key varchar(128) default '',
api_secret varchar(256) default '',
status char(1) default '0',
del_flag char(1) default '0',
login_ip varchar(128) default '',
@ -66,12 +68,13 @@ create table d_sys_user (
remark varchar(500) default null,
primary key (user_id)
);
CREATE UNIQUE INDEX uk_d_sys_user_api_key ON d_sys_user (api_key);
-- ----------------------------
-- 初始化-用户信息表数据
-- ----------------------------
insert into d_sys_user values('838fef38-55e6-4938-b995-b0814081e93c', 'a4209f68-23e3-45e4-88f4-2e2ceabf0851', 'admin', 'lm', '00', 'lm@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '管理员');
insert into d_sys_user values('568c27fd-4dcd-4871-8f43-56fc2ccf1b60', '6c442480-97e8-42b7-a509-08b458e44dcd', 'lm', 'lm', '00', 'lm@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '测试员');
insert into d_sys_user values('838fef38-55e6-4938-b995-b0814081e93c', 'a4209f68-23e3-45e4-88f4-2e2ceabf0851', 'admin', 'lm', '00', 'lm@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'ak_admin_demo', 'sk_admin_demo', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '管理员');
insert into d_sys_user values('568c27fd-4dcd-4871-8f43-56fc2ccf1b60', '6c442480-97e8-42b7-a509-08b458e44dcd', 'lm', 'lm', '00', 'lm@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'ak_lm_demo', 'sk_lm_demo', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '测试员');
-- ----------------------------

View File

@ -57,6 +57,12 @@ public class SysUser extends BaseEntity
/** 密码 */
private String password;
/** API Key对外接口调用 */
private String apiKey;
/** API Secret对外接口调用 */
private String apiSecret;
/** 账号状态0正常 1停用 */
@Excel(name = "账号状态", readConverterExp = "0=正常,1=停用")
private String status;
@ -222,6 +228,26 @@ public class SysUser extends BaseEntity
this.status = status;
}
public String getApiKey()
{
return apiKey;
}
public void setApiKey(String apiKey)
{
this.apiKey = apiKey;
}
public String getApiSecret()
{
return apiSecret;
}
public void setApiSecret(String apiSecret)
{
this.apiSecret = apiSecret;
}
public String getDelFlag()
{
return delFlag;
@ -324,6 +350,8 @@ public class SysUser extends BaseEntity
.append("sex", getSex())
.append("avatar", getAvatar())
.append("password", getPassword())
.append("apiKey", getApiKey())
.append("apiSecret", getApiSecret())
.append("status", getStatus())
.append("delFlag", getDelFlag())
.append("loginIp", getLoginIp())

View File

@ -40,13 +40,32 @@
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.llm.guard.auth.LlmAuthApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>repackage</goal>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>

View File

@ -1,6 +1,6 @@
# Tomcat
server:
port: 6200
port: 6204
# Spring
spring:

View File

@ -77,13 +77,32 @@
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.llm.guard.gateway.LlmGatewayApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>repackage</goal>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>

View File

@ -14,5 +14,6 @@
<modules>
<module>llm-guard-system</module>
<module>llm-guard-biz</module>
<module>llm-guard-open-api</module>
</modules>
</project>

View File

@ -86,13 +86,32 @@
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.llm.guard.biz.LlmBizApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>repackage</goal>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-modules</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>llm-guard-open-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<description>对外开放接口模块</description>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-datasource</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-log</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-swagger</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-security</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-mybatisplus</artifactId>
</dependency>
<dependency>
<groupId>com.googlecode.aviator</groupId>
<artifactId>aviator</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.llm.guard.openapi.LlmOpenApiApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-modules</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>llm-guard-open-api</artifactId>
<description>
对外开放接口模块
</description>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-datasource</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-log</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-swagger</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-security</artifactId>
</dependency>
<dependency>
<groupId>com.llm.guard</groupId>
<artifactId>llm-guard-common-mybatisplus</artifactId>
</dependency>
<dependency>
<groupId>com.googlecode.aviator</groupId>
<artifactId>aviator</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.llm.guard.openapi.LlmOpenApiApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,16 @@
package com.llm.guard.openapi;
import com.llm.guard.common.security.annotation.EnableCustomConfig;
import com.llm.guard.common.security.annotation.EnableLmFeignClients;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@EnableCustomConfig
@EnableLmFeignClients
@SpringBootApplication
public class LlmOpenApiApplication {
public static void main(String[] args) {
SpringApplication.run(LlmOpenApiApplication.class, args);
}
}

View File

@ -0,0 +1,98 @@
package com.llm.guard.openapi.controller;
import com.llm.guard.common.core.web.domain.AjaxResult;
import com.llm.guard.openapi.domain.OpenApiGuardCheckRequest;
import com.llm.guard.openapi.domain.OpenApiGuardRequest;
import com.llm.guard.openapi.domain.OpenApiGuardResponse;
import com.llm.guard.openapi.service.OpenApiGuardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/openapi/guard")
@Tag(name = "开放接口-防护检测")
@AllArgsConstructor
public class OpenApiGuardController {
private final OpenApiGuardService openApiGuardService;
@Operation(summary = "统一防护检测接口ACL/ATTACK/CONTENT")
@PostMapping("/check")
public AjaxResult check(
@RequestHeader("X-Api-Key") String apiKey,
@RequestHeader("X-Api-Secret") String apiSecret,
@RequestBody OpenApiGuardCheckRequest request
) {
OpenApiGuardService.CallerAuthResult authResult = openApiGuardService.authenticate(apiKey, apiSecret);
if (!authResult.isSuccess()) {
return AjaxResult.error(401, authResult.getMessage());
}
Object reqData = request == null ? null : request.getReqData();
if (reqData == null) {
return AjaxResult.error(400, "reqData 不能为空");
}
OpenApiGuardRequest guardRequest = buildGuardRequest(request);
OpenApiGuardResponse response;
try {
response = openApiGuardService.check(guardRequest, authResult.getUserName());
} catch (IllegalArgumentException e) {
return AjaxResult.error(400, e.getMessage());
}
return AjaxResult.success(buildMessage(response), response);
}
private OpenApiGuardRequest buildGuardRequest(OpenApiGuardCheckRequest request)
{
OpenApiGuardRequest guardRequest = new OpenApiGuardRequest();
guardRequest.setSourceIp(request.getSourceIp());
guardRequest.setPath(request.getPath());
guardRequest.setMethod(request.getMethod());
guardRequest.setInterfaceType(request.getInterfaceType());
guardRequest.setCallerId(request.getCallerId());
guardRequest.setPayload(request.getReqData());
if (request.getReqData() instanceof Map<?, ?> map)
{
Map<String, Object> extensions = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet())
{
extensions.put(String.valueOf(entry.getKey()), entry.getValue());
}
guardRequest.setExtensions(extensions);
}
return guardRequest;
}
private String buildMessage(OpenApiGuardResponse response)
{
if (Boolean.FALSE.equals(response.getAlerted()) || response.getHits() == null || response.getHits().isEmpty())
{
return "未命中规则,允许通过";
}
Set<String> messages = new LinkedHashSet<>();
response.getHits().forEach(hit -> {
if (hit.getMessage() != null && !hit.getMessage().isBlank())
{
messages.add(hit.getMessage());
}
});
if (messages.isEmpty())
{
return "命中规则";
}
return String.join("", messages);
}
}

View File

@ -0,0 +1,27 @@
package com.llm.guard.openapi.domain;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "开放防护检测请求包装")
public class OpenApiGuardCheckRequest {
@Schema(description = "来源IPACL IP 规则依赖)", requiredMode = Schema.RequiredMode.REQUIRED)
private String sourceIp;
@Schema(description = "请求路径ACL 接口规则依赖)", requiredMode = Schema.RequiredMode.REQUIRED)
private String path;
@Schema(description = "请求方法ACL 接口规则依赖)", requiredMode = Schema.RequiredMode.REQUIRED)
private String method;
@Schema(description = "接口类型AGENT/LLM/VLM 分流)", requiredMode = Schema.RequiredMode.REQUIRED)
private String interfaceType;
@Schema(description = "调用方标识(调用方追踪)", requiredMode = Schema.RequiredMode.REQUIRED)
private String callerId;
@Schema(description = "业务请求体(固定字段,透传网关原始入参)", requiredMode = Schema.RequiredMode.REQUIRED)
private Object reqData;
}

View File

@ -0,0 +1,64 @@
package com.llm.guard.openapi.domain;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "开放防护检测请求")
public class OpenApiGuardRequest {
@Schema(description = "请求ID调用方可自定义")
private String requestId;
@Schema(description = "链路追踪ID")
private String traceId;
@Schema(description = "作用域编码对应规则scopeCode")
private String scopeCode;
@Schema(description = "接口类型: AGENT/LLM/VLM")
private String interfaceType;
@Schema(description = "请求路径")
private String path;
@Schema(description = "请求方法")
private String method;
@Schema(description = "来源IP")
private String sourceIp;
@Schema(description = "调用方标识")
private String callerId;
@Schema(description = "是否流式")
private Boolean stream;
@Schema(description = "智能体会话文本")
private String text;
@Schema(description = "消息体(兼容语义模型/多模态)")
private List<Map<String, Object>> messages;
@Schema(description = "文件列表")
private List<Map<String, Object>> files;
@Schema(description = "附加元数据")
private Map<String, Object> metadata;
@Schema(description = "原始请求体,支持任意结构")
private Object payload;
@Schema(description = "扩展字段(透传任意入参)")
private Map<String, Object> extensions = new LinkedHashMap<>();
@JsonAnySetter
public void putExtension(String key, Object value) {
this.extensions.put(key, value);
}
}

View File

@ -0,0 +1,27 @@
package com.llm.guard.openapi.domain;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "开放防护检测响应")
public class OpenApiGuardResponse {
@Schema(description = "请求ID")
private String requestId;
@Schema(description = "链路ID")
private String traceId;
@Schema(description = "最终决策: ALLOW/BLOCK")
private String decision;
@Schema(description = "是否触发告警")
private Boolean alerted;
@Schema(description = "命中列表")
private List<OpenApiHitResp> hits = new ArrayList<>();
}

View File

@ -0,0 +1,31 @@
package com.llm.guard.openapi.domain;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "命中明细")
public class OpenApiHitResp {
@Schema(description = "模块: ACL/ATTACK/CONTENT")
private String moduleType;
@Schema(description = "事件类型")
private String eventType;
@Schema(description = "规则ID")
private String ruleId;
@Schema(description = "规则编码")
private String ruleCode;
@Schema(description = "动作: ALLOW/BLOCK/ALERT/MASK/REPLACE")
private String action;
@Schema(description = "命中说明")
private String message;
}

View File

@ -0,0 +1,801 @@
package com.llm.guard.openapi.service;
import com.googlecode.aviator.AviatorEvaluator;
import com.llm.guard.common.core.utils.StringUtils;
import com.llm.guard.common.core.utils.uuid.IdUtils;
import com.llm.guard.openapi.domain.OpenApiGuardRequest;
import com.llm.guard.openapi.domain.OpenApiGuardResponse;
import com.llm.guard.openapi.domain.OpenApiHitResp;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
@Service
@AllArgsConstructor
public class OpenApiGuardService {
private static final String ENABLED = "ENABLED";
private final JdbcTemplate jdbcTemplate;
public CallerAuthResult authenticate(String apiKey, String apiSecret) {
if (StringUtils.isAnyBlank(apiKey, apiSecret)) {
return new CallerAuthResult(false, null, "apiKey/apiSecret 不能为空");
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT user_id, user_name, status, del_flag, api_secret FROM d_sys_user WHERE api_key = ? LIMIT 1",
apiKey
);
if (rows.isEmpty()) {
return new CallerAuthResult(false, null, "apiKey 不存在");
}
Map<String, Object> row = rows.get(0);
String status = str(row, "status");
String delFlag = str(row, "del_flag");
if (!"0".equals(status) || !"0".equals(delFlag)) {
return new CallerAuthResult(false, null, "账号不可用");
}
String dbSecret = str(row, "api_secret");
if (!Objects.equals(dbSecret, apiSecret)) {
return new CallerAuthResult(false, null, "apiSecret 错误");
}
return new CallerAuthResult(true, str(row, "user_name"), null);
}
public OpenApiGuardResponse check(OpenApiGuardRequest request, String apiCaller) {
normalizeRequest(request);
validateRequiredFields(request);
OpenApiGuardResponse response = new OpenApiGuardResponse();
response.setRequestId(request.getRequestId());
response.setTraceId(request.getTraceId());
response.setDecision("ALLOW");
response.setAlerted(Boolean.FALSE);
String corpus = buildCorpus(request).toLowerCase(Locale.ROOT);
List<MatchHit> hits = new ArrayList<>();
hits.addAll(checkAclRules(request, corpus));
hits.addAll(checkAttackRules(request, corpus));
hits.addAll(checkContentRules(request, corpus));
if (!hits.isEmpty()) {
response.setAlerted(Boolean.TRUE);
for (MatchHit hit : hits) {
response.getHits().add(new OpenApiHitResp(hit.moduleType, hit.eventType, hit.ruleId, hit.ruleCode, hit.action, hit.message));
}
boolean blocked = hits.stream().anyMatch(hit -> "BLOCK".equalsIgnoreCase(hit.action));
response.setDecision(blocked ? "BLOCK" : "ALLOW");
saveAlertLogs(request, apiCaller, hits);
}
return response;
}
private void normalizeRequest(OpenApiGuardRequest request) {
if (StringUtils.isBlank(request.getRequestId())) {
request.setRequestId(firstString(request, "requestId", "request_id", "reqId", "req_id"));
}
if (StringUtils.isBlank(request.getRequestId())) {
request.setRequestId(IdUtils.fastSimpleUUID());
}
if (StringUtils.isBlank(request.getTraceId())) {
request.setTraceId(firstString(request, "traceId", "trace_id"));
}
if (StringUtils.isBlank(request.getTraceId())) {
request.setTraceId(IdUtils.fastSimpleUUID());
}
if (StringUtils.isBlank(request.getScopeCode())) {
request.setScopeCode(firstString(request, "scopeCode", "scope_code", "scope"));
}
if (StringUtils.isBlank(request.getScopeCode())) {
request.setScopeCode("GLOBAL");
}
if (StringUtils.isBlank(request.getInterfaceType())) {
request.setInterfaceType(firstString(request, "interfaceType", "interface_type", "sceneType", "scene_type"));
}
if (StringUtils.isBlank(request.getPath())) {
request.setPath(firstString(request, "path", "requestPath", "request_path", "uri", "url"));
}
if (StringUtils.isBlank(request.getMethod())) {
request.setMethod(firstString(request, "method", "httpMethod", "http_method", "requestMethod", "request_method"));
}
if (StringUtils.isBlank(request.getSourceIp())) {
request.setSourceIp(firstString(request, "sourceIp", "source_ip", "clientIp", "client_ip", "remoteIp", "remote_ip", "requestIp", "request_ip", "ip"));
}
if (StringUtils.isBlank(request.getCallerId())) {
request.setCallerId(firstString(request, "callerId", "caller_id", "appId", "app_id", "clientId", "client_id", "tenantId", "tenant_id"));
}
if (StringUtils.isBlank(request.getText())) {
request.setText(firstString(request, "text", "prompt", "query", "input", "content"));
}
if (request.getStream() == null) {
request.setStream(firstBoolean(request, "stream", "isStream", "is_stream"));
}
if (request.getStream() == null) {
request.setStream(Boolean.FALSE);
}
}
private void validateRequiredFields(OpenApiGuardRequest request) {
List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(request.getSourceIp())) {
missing.add("sourceIp");
}
if (StringUtils.isBlank(request.getPath())) {
missing.add("path");
}
if (StringUtils.isBlank(request.getMethod())) {
missing.add("method");
}
if (StringUtils.isBlank(request.getInterfaceType())) {
missing.add("interfaceType");
}
if (StringUtils.isBlank(request.getCallerId())) {
missing.add("callerId");
}
if (!missing.isEmpty()) {
throw new IllegalArgumentException("reqData 缺少必填字段: " + String.join(", ", missing));
}
}
private List<MatchHit> checkAclRules(OpenApiGuardRequest request, String corpus) {
List<MatchHit> hits = new ArrayList<>();
if (StringUtils.isNotBlank(request.getSourceIp())) {
List<Map<String, Object>> ipRules = jdbcTemplate.queryForList(
"SELECT id, list_type, ip_type, ip_value FROM d_acl_ip_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0 ORDER BY priority ASC",
request.getScopeCode(), ENABLED
);
List<Map<String, Object>> whiteListRules = ipRules.stream()
.filter(row -> "WHITELIST".equalsIgnoreCase(str(row, "list_type")))
.toList();
boolean whiteMatched = whiteListRules.isEmpty();
for (Map<String, Object> row : ipRules) {
String ipType = str(row, "ip_type");
String ipValue = str(row, "ip_value");
if (!matchIp(request.getSourceIp(), ipType, ipValue)) {
continue;
}
String listType = str(row, "list_type");
if ("WHITELIST".equalsIgnoreCase(listType)) {
whiteMatched = true;
} else if ("BLACKLIST".equalsIgnoreCase(listType)) {
hits.add(new MatchHit("ACL", "ACL_IP_BLACKLIST", str(row, "id"), null, "BLOCK", "命中IP黑名单规则"));
return hits;
}
}
if (!whiteMatched) {
hits.add(new MatchHit("ACL", "ACL_IP_WHITELIST", null, null, "BLOCK", "来源IP不在白名单"));
return hits;
}
}
if (StringUtils.isNotBlank(request.getPath())) {
List<Map<String, Object>> endpointRules = jdbcTemplate.queryForList(
"SELECT id, match_type, uri_pattern, methods FROM d_acl_endpoint_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0",
request.getScopeCode(), ENABLED
);
for (Map<String, Object> row : endpointRules) {
if (!matchMethod(request.getMethod(), str(row, "methods"))) {
continue;
}
if (matchPath(request.getPath(), str(row, "match_type"), str(row, "uri_pattern"))) {
hits.add(new MatchHit("ACL", "ACL_ENDPOINT_BLOCK", str(row, "id"), null, "BLOCK", "命中接口封堵规则"));
return hits;
}
}
}
List<Map<String, Object>> customRules = jdbcTemplate.queryForList(
"SELECT id, rule_code, logic_op, action FROM d_acl_custom_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0 ORDER BY priority ASC",
request.getScopeCode(), ENABLED
);
Map<String, Object> env = buildAviatorEnv(request, corpus);
for (Map<String, Object> rule : customRules) {
List<Map<String, Object>> conds = jdbcTemplate.queryForList(
"SELECT condition_expr FROM d_acl_custom_condition WHERE rule_id = ? AND is_deleted = 0 ORDER BY seq_no ASC",
str(rule, "id")
);
if (conds.isEmpty()) {
continue;
}
String logicOp = str(rule, "logic_op");
boolean matched = "OR".equalsIgnoreCase(logicOp) ? false : true;
for (Map<String, Object> cond : conds) {
boolean one = evalCondition(str(cond, "condition_expr"), env);
if ("OR".equalsIgnoreCase(logicOp)) {
matched = matched || one;
} else {
matched = matched && one;
}
}
if (matched) {
String action = upperOrDefault(val(rule, "action"), "ALERT");
hits.add(new MatchHit("ACL", "ACL_CUSTOM_RULE", str(rule, "id"), str(rule, "rule_code"), action, "命中自定义组合规则"));
if ("BLOCK".equalsIgnoreCase(action)) {
return hits;
}
}
}
return hits;
}
private List<MatchHit> checkAttackRules(OpenApiGuardRequest request, String corpus) {
List<MatchHit> hits = new ArrayList<>();
Map<String, Boolean> switches = loadAttackSwitches(request.getScopeCode());
Map<String, String> actionByRule = new HashMap<>();
List<Map<String, Object>> rules = jdbcTemplate.queryForList(
"SELECT id, rule_code, category, sub_type, action FROM d_attack_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0",
request.getScopeCode(), ENABLED
);
List<Map<String, Object>> signatures = jdbcTemplate.queryForList(
"SELECT id, sig_type, sig_value, rule_code FROM d_attack_signature WHERE scope_code = ? AND status = ? AND is_deleted = 0",
request.getScopeCode(), ENABLED
);
for (Map<String, Object> rule : rules) {
String switchKey = buildSwitchKey(str(rule, "category"), str(rule, "sub_type"));
if (switches.containsKey(switchKey) && !Boolean.TRUE.equals(switches.get(switchKey))) {
continue;
}
actionByRule.put(str(rule, "rule_code"), upperOrDefault(val(rule, "action"), "ALERT"));
}
for (Map<String, Object> sig : signatures) {
String ruleCode = str(sig, "rule_code");
if (!actionByRule.containsKey(ruleCode)) {
continue;
}
if (matchSignature(corpus, str(sig, "sig_type"), str(sig, "sig_value"))) {
String action = actionByRule.get(ruleCode);
hits.add(new MatchHit("ATTACK", "ATTACK_SIGNATURE", str(sig, "id"), ruleCode, action, "命中攻击特征"));
if ("BLOCK".equalsIgnoreCase(action)) {
return hits;
}
}
}
return hits;
}
private List<MatchHit> checkContentRules(OpenApiGuardRequest request, String corpus) {
List<MatchHit> hits = new ArrayList<>();
List<Map<String, Object>> dlpRules = jdbcTemplate.queryForList(
"SELECT id, rule_code, data_type, action FROM d_content_dlp_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0",
request.getScopeCode(), ENABLED
);
for (Map<String, Object> rule : dlpRules) {
if (matchDataType(corpus, str(rule, "data_type"))) {
String action = upperOrDefault(val(rule, "action"), "ALERT");
hits.add(new MatchHit("CONTENT", "CONTENT_DLP", str(rule, "id"), str(rule, "rule_code"), action, "命中内容DLP规则"));
if ("BLOCK".equalsIgnoreCase(action)) {
return hits;
}
}
}
List<Map<String, Object>> contentPolicies = jdbcTemplate.queryForList(
"SELECT id, policy_code, action FROM d_content_policy WHERE scope_code = ? AND status = ? AND is_deleted = 0",
request.getScopeCode(), ENABLED
);
for (Map<String, Object> policy : contentPolicies) {
String policyCode = str(policy, "policy_code");
if (StringUtils.isNotBlank(policyCode) && corpus.contains(policyCode.toLowerCase(Locale.ROOT))) {
String action = upperOrDefault(val(policy, "action"), "ALERT");
hits.add(new MatchHit("CONTENT", "CONTENT_POLICY", str(policy, "id"), policyCode, action, "命中内容策略编码关键字"));
if ("BLOCK".equalsIgnoreCase(action)) {
return hits;
}
}
}
List<Map<String, Object>> maskPolicies = jdbcTemplate.queryForList(
"SELECT id, policy_code, template_name, action FROM d_content_mask_policy WHERE scope_code = ? AND status = ? AND is_deleted = 0",
request.getScopeCode(), ENABLED
);
for (Map<String, Object> policy : maskPolicies) {
String template = str(policy, "template_name");
if (StringUtils.isNotBlank(template) && corpus.contains(template.toLowerCase(Locale.ROOT))) {
String action = upperOrDefault(val(policy, "action"), "MASK");
hits.add(new MatchHit("CONTENT", "CONTENT_MASK", str(policy, "id"), str(policy, "policy_code"), action, "命中脱敏模板关键字"));
if ("BLOCK".equalsIgnoreCase(action)) {
return hits;
}
}
}
return hits;
}
private void saveAlertLogs(OpenApiGuardRequest request, String apiCaller, List<MatchHit> hits) {
String severity = resolveSeverity(request.getScopeCode());
Timestamp now = new Timestamp(System.currentTimeMillis());
Date partitionDate = Date.valueOf(LocalDate.now());
for (MatchHit hit : hits) {
String eventNo = "EVT_" + IdUtils.fastSimpleUUID();
Long eventId = insertAlertEventAndReturnId(
eventNo,
request,
hit,
severity,
now,
partitionDate,
apiCaller
);
if (eventId != null) {
jdbcTemplate.update(
"INSERT INTO d_log_alert_hit (event_id, hit_order, hit_target, hit_field, hit_operator, expected_value, actual_value_preview, confidence, create_by, create_time, update_by, update_time, is_deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)",
eventId,
1,
hit.moduleType,
hit.ruleCode,
hit.eventType,
"rule_matched",
hit.message,
1.0,
"open-api",
now,
"open-api",
now
);
}
}
}
private Long insertAlertEventAndReturnId(
String eventNo,
OpenApiGuardRequest request,
MatchHit hit,
String severity,
Timestamp now,
Date partitionDate,
String apiCaller
) {
String sql = "INSERT INTO d_log_alert_event (event_no, request_id, trace_id, scope_code, module_type, event_type, rule_type, rule_id, rule_code, severity, action_taken, hit_message, request_path, request_method, source_ip, caller_id, is_stream, alert_status, occurred_at, partition_date, create_by, create_time, update_by, update_time, is_deleted) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)";
KeyHolder keyHolder = new GeneratedKeyHolder();
PreparedStatementCreator psc = (Connection conn) -> {
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
int i = 1;
ps.setString(i++, eventNo);
ps.setString(i++, request.getRequestId());
ps.setString(i++, request.getTraceId());
ps.setString(i++, request.getScopeCode());
ps.setString(i++, hit.moduleType);
ps.setString(i++, hit.eventType);
ps.setString(i++, hit.eventType);
ps.setString(i++, hit.ruleId);
ps.setString(i++, hit.ruleCode);
ps.setString(i++, severity);
ps.setString(i++, upperOrDefault(hit.action, "ALERT"));
ps.setString(i++, hit.message);
ps.setString(i++, request.getPath());
ps.setString(i++, request.getMethod());
ps.setString(i++, request.getSourceIp());
ps.setString(i++, StringUtils.isNotBlank(request.getCallerId()) ? request.getCallerId() : apiCaller);
ps.setInt(i++, Boolean.TRUE.equals(request.getStream()) ? 1 : 0);
ps.setString(i++, "NEW");
ps.setTimestamp(i++, now);
ps.setDate(i++, partitionDate);
ps.setString(i++, "open-api");
ps.setTimestamp(i++, now);
ps.setString(i++, "open-api");
ps.setTimestamp(i, now);
return ps;
};
jdbcTemplate.update(psc, keyHolder);
return extractGeneratedId(keyHolder);
}
private Long extractGeneratedId(KeyHolder keyHolder) {
if (keyHolder == null) {
return null;
}
Map<String, Object> one = keyHolder.getKeys();
Long oneId = extractIdFromMap(one);
if (oneId != null) {
return oneId;
}
if (keyHolder.getKeyList() != null) {
for (Map<String, Object> m : keyHolder.getKeyList()) {
Long id = extractIdFromMap(m);
if (id != null) {
return id;
}
}
}
return null;
}
private Long extractIdFromMap(Map<String, Object> map) {
if (map == null || map.isEmpty()) {
return null;
}
Object idObj = map.get("id");
if (idObj == null) {
for (Map.Entry<String, Object> entry : map.entrySet()) {
if ("id".equalsIgnoreCase(entry.getKey())) {
idObj = entry.getValue();
break;
}
}
}
if (idObj instanceof Number n) {
return n.longValue();
}
if (idObj != null) {
try {
return Long.valueOf(String.valueOf(idObj));
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String resolveSeverity(String scopeCode) {
List<Map<String, Object>> list = jdbcTemplate.queryForList(
"SELECT alert_level FROM d_log_attack_policy WHERE scope_code = ? AND status = ? AND is_deleted = 0 ORDER BY update_time DESC LIMIT 1",
scopeCode, ENABLED
);
if (list.isEmpty()) {
return "MEDIUM";
}
return upperOrDefault(val(list.get(0), "alert_level"), "MEDIUM");
}
private Map<String, Boolean> loadAttackSwitches(String scopeCode) {
List<Map<String, Object>> switches = jdbcTemplate.queryForList(
"SELECT switch_key, enabled FROM d_attack_switch WHERE scope_code = ? AND is_deleted = 0",
scopeCode
);
if (switches.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Boolean> map = new HashMap<>();
for (Map<String, Object> sw : switches) {
map.put(str(sw, "switch_key"), toBool(val(sw, "enabled")));
}
return map;
}
private Map<String, Object> buildAviatorEnv(OpenApiGuardRequest request, String corpus) {
Map<String, Object> env = new HashMap<>();
env.put("requestId", request.getRequestId());
env.put("traceId", request.getTraceId());
env.put("scopeCode", request.getScopeCode());
env.put("interfaceType", request.getInterfaceType());
env.put("path", request.getPath());
env.put("method", request.getMethod());
env.put("sourceIp", request.getSourceIp());
env.put("callerId", request.getCallerId());
env.put("stream", request.getStream());
env.put("text", request.getText());
env.put("extensions", request.getExtensions());
env.put("corpus", corpus);
env.put("metadata", request.getMetadata());
return env;
}
private boolean evalCondition(String expr, Map<String, Object> env) {
try {
Object ret = AviatorEvaluator.execute(expr, env, true);
if (ret instanceof Boolean b) {
return b;
}
return false;
} catch (Exception ignored) {
return false;
}
}
private boolean matchSignature(String corpus, String sigType, String sigValue) {
if (StringUtils.isBlank(sigValue)) {
return false;
}
try {
if ("REGEX".equalsIgnoreCase(sigType)) {
return Pattern.compile(sigValue, Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(corpus).find();
}
return corpus.contains(sigValue.toLowerCase(Locale.ROOT));
} catch (Exception ignored) {
return false;
}
}
private boolean matchDataType(String corpus, String dataType) {
if (StringUtils.isBlank(dataType)) {
return false;
}
String t = dataType.toUpperCase(Locale.ROOT);
return switch (t) {
case "EMAIL" -> Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}").matcher(corpus).find();
case "PHONE" -> Pattern.compile("(?:\\+?86[- ]?)?1[3-9]\\d{9}").matcher(corpus).find();
case "ID_CARD" -> Pattern.compile("[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]").matcher(corpus).find();
case "BANK_CARD" -> Pattern.compile("\\b(?:\\d[ -]*?){13,19}\\b").matcher(corpus).find();
default -> corpus.contains(dataType.toLowerCase(Locale.ROOT));
};
}
private boolean matchIp(String sourceIp, String ipType, String ipValue) {
if (StringUtils.isAnyBlank(sourceIp, ipType, ipValue)) {
return false;
}
try {
return switch (ipType.toUpperCase(Locale.ROOT)) {
case "SINGLE" -> Objects.equals(sourceIp, ipValue);
case "REGEX" -> Pattern.compile(ipValue).matcher(sourceIp).find();
case "CIDR" -> matchCidr(sourceIp, ipValue);
default -> false;
};
} catch (Exception ignored) {
return false;
}
}
private boolean matchCidr(String ip, String cidr) {
String[] parts = cidr.split("/");
if (parts.length != 2) {
return false;
}
long ipNum = ipv4ToLong(ip);
long baseNum = ipv4ToLong(parts[0]);
int prefix = Integer.parseInt(parts[1]);
long mask = prefix == 0 ? 0 : 0xFFFFFFFFL << (32 - prefix);
return (ipNum & mask) == (baseNum & mask);
}
private long ipv4ToLong(String ip) {
String[] arr = ip.split("\\.");
if (arr.length != 4) {
throw new IllegalArgumentException("invalid ipv4");
}
long n = 0;
for (String s : arr) {
n = (n << 8) + Integer.parseInt(s);
}
return n;
}
private boolean matchMethod(String reqMethod, String configuredMethods) {
if (StringUtils.isBlank(configuredMethods)) {
return true;
}
String m = reqMethod == null ? "" : reqMethod.toUpperCase(Locale.ROOT);
String cfg = configuredMethods.toUpperCase(Locale.ROOT);
if ("ALL".equals(cfg)) {
return true;
}
for (String item : cfg.split(",")) {
if (m.equals(item.trim())) {
return true;
}
}
return false;
}
private boolean matchPath(String path, String matchType, String pattern) {
if (StringUtils.isAnyBlank(path, matchType, pattern)) {
return false;
}
try {
return switch (matchType.toUpperCase(Locale.ROOT)) {
case "EXACT" -> Objects.equals(path, pattern);
case "PREFIX" -> path.startsWith(pattern);
case "REGEX" -> Pattern.compile(pattern).matcher(path).find();
default -> false;
};
} catch (Exception ignored) {
return false;
}
}
private String buildSwitchKey(String category, String subType) {
String c = StringUtils.isBlank(category) ? "ATTACK" : category.toUpperCase(Locale.ROOT);
String s = StringUtils.isBlank(subType) ? "DEFAULT" : subType.toUpperCase(Locale.ROOT);
return c + "_" + s;
}
private String buildCorpus(OpenApiGuardRequest request) {
StringBuilder sb = new StringBuilder();
appendIfPresent(sb, request.getText());
appendIfPresent(sb, request.getPath());
appendIfPresent(sb, request.getMethod());
appendIfPresent(sb, request.getCallerId());
if (request.getMessages() != null) {
for (Map<String, Object> msg : request.getMessages()) {
appendFromObject(sb, msg);
}
}
if (request.getFiles() != null) {
for (Map<String, Object> file : request.getFiles()) {
appendFromObject(sb, file);
}
}
appendFromObject(sb, request.getMetadata());
appendFromObject(sb, request.getPayload());
appendFromObject(sb, request.getExtensions());
return sb.toString();
}
private String firstString(OpenApiGuardRequest request, String... aliases) {
Object v = findInExtensions(request, aliases);
if (v == null) {
return null;
}
String s = String.valueOf(v);
return StringUtils.isBlank(s) ? null : s;
}
private Boolean firstBoolean(OpenApiGuardRequest request, String... aliases) {
Object v = findInExtensions(request, aliases);
if (v == null) {
return null;
}
return toBool(v);
}
private Object findInExtensions(OpenApiGuardRequest request, String... aliases) {
if (request == null || request.getExtensions() == null || request.getExtensions().isEmpty() || aliases == null || aliases.length == 0) {
return null;
}
Set<String> names = new HashSet<>();
for (String alias : aliases) {
if (StringUtils.isNotBlank(alias)) {
names.add(alias.toLowerCase(Locale.ROOT));
}
}
return findValueByName(request.getExtensions(), names);
}
private Object findValueByName(Object node, Set<String> aliasNames) {
if (node == null || aliasNames == null || aliasNames.isEmpty()) {
return null;
}
if (node instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = String.valueOf(entry.getKey());
if (aliasNames.contains(key.toLowerCase(Locale.ROOT))) {
return entry.getValue();
}
}
for (Map.Entry<?, ?> entry : map.entrySet()) {
Object nested = findValueByName(entry.getValue(), aliasNames);
if (nested != null) {
return nested;
}
}
return null;
}
if (node instanceof Iterable<?> iterable) {
for (Object item : iterable) {
Object nested = findValueByName(item, aliasNames);
if (nested != null) {
return nested;
}
}
return null;
}
return null;
}
private void appendFromObject(StringBuilder sb, Object obj) {
if (obj == null) {
return;
}
if (obj instanceof String s) {
appendIfPresent(sb, s);
return;
}
if (obj instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
appendIfPresent(sb, String.valueOf(entry.getKey()));
appendFromObject(sb, entry.getValue());
}
return;
}
if (obj instanceof Iterable<?> iterable) {
for (Object item : iterable) {
appendFromObject(sb, item);
}
return;
}
appendIfPresent(sb, String.valueOf(obj));
}
private void appendIfPresent(StringBuilder sb, String value) {
if (StringUtils.isNotBlank(value)) {
sb.append(value).append(' ');
}
}
private boolean toBool(Object value) {
if (value == null) {
return false;
}
if (value instanceof Boolean b) {
return b;
}
if (value instanceof Number n) {
return n.intValue() == 1;
}
return "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value));
}
private String upperOrDefault(Object value, String defVal) {
String s = objToStr(value);
return StringUtils.isBlank(s) ? defVal : s.toUpperCase(Locale.ROOT);
}
private String str(Map<String, Object> row, String key) {
return objToStr(val(row, key));
}
private Object val(Map<String, Object> row, String key) {
if (row.containsKey(key)) {
return row.get(key);
}
for (Map.Entry<String, Object> entry : row.entrySet()) {
if (key.equalsIgnoreCase(entry.getKey())) {
return entry.getValue();
}
}
return null;
}
private static String objToStr(Object value) {
return value == null ? null : String.valueOf(value);
}
@Data
@AllArgsConstructor
public static class CallerAuthResult {
private boolean success;
private String userName;
private String message;
}
@Data
@AllArgsConstructor
private static class MatchHit {
private String moduleType;
private String eventType;
private String ruleId;
private String ruleCode;
private String action;
private String message;
}
}

View File

@ -0,0 +1,23 @@
server:
port: 6200
spring:
application:
name: llm-guard-open-api
profiles:
active: dev
cloud:
nacos:
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
server-addr: ${NACOS_HOST:127.0.0.1}:${NACOS_PORT:8848}
namespace: ${NACOS_DISCOVERY_NAMESPACE:dev}
config:
server-addr: ${NACOS_HOST:127.0.0.1}:${NACOS_PORT:8848}
namespace: ${NACOS_DISCOVERY_NAMESPACE:dev}
file-extension: yml
config:
import:
- optional:nacos:application-${spring.profiles.active}.yml
- optional:nacos:${spring.application.name}-${spring.profiles.active}.yml

View File

@ -0,0 +1,231 @@
package com.llm.guard.openapi;
import com.llm.guard.common.core.web.domain.AjaxResult;
import com.llm.guard.openapi.controller.OpenApiGuardController;
import com.llm.guard.openapi.domain.OpenApiGuardCheckRequest;
import com.llm.guard.openapi.domain.OpenApiGuardRequest;
import com.llm.guard.openapi.domain.OpenApiGuardResponse;
import com.llm.guard.openapi.service.OpenApiGuardService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OpenApiGuardSimulationTest {
private JdbcTemplate jdbcTemplate;
private OpenApiGuardController controller;
@BeforeEach
void setUp() {
DataSource dataSource = new DriverManagerDataSource("jdbc:h2:mem:guard;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
jdbcTemplate = new JdbcTemplate(dataSource);
OpenApiGuardService service = new OpenApiGuardService(jdbcTemplate);
controller = new OpenApiGuardController(service);
initSchema();
seedAuthUser();
seedRules();
}
@Test
void should_simulate_three_gateway_requests_and_verify_alerts() {
OpenApiGuardRequest agentReq = new OpenApiGuardRequest();
agentReq.setRequestId("req-agent-1");
agentReq.setTraceId("trace-agent-1");
agentReq.setScopeCode("GLOBAL");
agentReq.setInterfaceType("AGENT");
agentReq.setCallerId("partnerA");
agentReq.setPath("/api/session/run");
agentReq.setMethod("POST");
agentReq.setSourceIp("10.0.0.1");
agentReq.setText("你好");
AjaxResult agentResult = controller.check("ak_test", "sk_test", wrap(agentReq));
assertEquals(200, agentResult.get("code"));
OpenApiGuardResponse agentResp = (OpenApiGuardResponse) agentResult.get("data");
assertEquals("BLOCK", agentResp.getDecision());
assertTrue(agentResp.getAlerted());
assertEquals("ACL", agentResp.getHits().get(0).getModuleType());
assertEquals(1, countAlertByRequestId("req-agent-1"));
OpenApiGuardRequest vlmReq = new OpenApiGuardRequest();
vlmReq.setRequestId("req-vlm-1");
vlmReq.setTraceId("trace-vlm-1");
vlmReq.setScopeCode("GLOBAL");
vlmReq.setInterfaceType("VLM");
vlmReq.setCallerId("partnerA");
vlmReq.setPath("/api/vlm/chat/completion/V2");
vlmReq.setMethod("POST");
vlmReq.setSourceIp("10.0.0.1");
vlmReq.setMessages(List.of(Map.of("role", "user", "content", "please union select password")));
AjaxResult vlmResult = controller.check("ak_test", "sk_test", wrap(vlmReq));
assertEquals(200, vlmResult.get("code"));
OpenApiGuardResponse vlmResp = (OpenApiGuardResponse) vlmResult.get("data");
assertEquals("BLOCK", vlmResp.getDecision());
assertTrue(vlmResp.getAlerted());
assertEquals("ATTACK", vlmResp.getHits().get(0).getModuleType());
assertEquals(1, countAlertByRequestId("req-vlm-1"));
OpenApiGuardRequest llmReq = new OpenApiGuardRequest();
llmReq.setRequestId("req-llm-1");
llmReq.setTraceId("trace-llm-1");
llmReq.setScopeCode("GLOBAL");
llmReq.setInterfaceType("LLM");
llmReq.setCallerId("partnerA");
llmReq.setPath("/api/llm/chat/completion/V2");
llmReq.setMethod("POST");
llmReq.setSourceIp("10.0.0.1");
llmReq.setMessages(List.of(Map.of("role", "user", "content", "我的邮箱是 test_user@demo.com")));
AjaxResult llmResult = controller.check("ak_test", "sk_test", wrap(llmReq));
assertEquals(200, llmResult.get("code"));
OpenApiGuardResponse llmResp = (OpenApiGuardResponse) llmResult.get("data");
assertEquals("ALLOW", llmResp.getDecision());
assertTrue(llmResp.getAlerted());
assertEquals("CONTENT", llmResp.getHits().get(0).getModuleType());
assertEquals(1, countAlertByRequestId("req-llm-1"));
Integer totalEvent = jdbcTemplate.queryForObject("select count(1) from d_log_alert_event", Integer.class);
Integer totalHit = jdbcTemplate.queryForObject("select count(1) from d_log_alert_hit", Integer.class);
assertEquals(3, totalEvent);
assertEquals(3, totalHit);
OpenApiGuardRequest cleanReq = new OpenApiGuardRequest();
cleanReq.setRequestId("req-clean-1");
cleanReq.setTraceId("trace-clean-1");
cleanReq.setScopeCode("GLOBAL");
cleanReq.setInterfaceType("LLM");
cleanReq.setCallerId("partnerA");
cleanReq.setPath("/api/normal");
cleanReq.setMethod("POST");
cleanReq.setSourceIp("10.0.0.1");
cleanReq.setText("hello world");
AjaxResult cleanResult = controller.check("ak_test", "sk_test", wrap(cleanReq));
OpenApiGuardResponse cleanResp = (OpenApiGuardResponse) cleanResult.get("data");
assertFalse(cleanResp.getAlerted());
assertEquals("ALLOW", cleanResp.getDecision());
}
@Test
void should_accept_raw_gateway_payload_without_fixed_fields() {
OpenApiGuardRequest rawReq = new OpenApiGuardRequest();
rawReq.setInterfaceType("LLM");
rawReq.setCallerId("partnerA");
rawReq.setPath("/api/llm/chat/completion/V2");
rawReq.setMethod("POST");
rawReq.setSourceIp("10.0.0.1");
rawReq.getExtensions().put("requestId", "req-raw-1");
rawReq.getExtensions().put("traceId", "trace-raw-1");
rawReq.getExtensions().put("scopeCode", "GLOBAL");
rawReq.getExtensions().put("messages", List.of(Map.of("role", "user", "content", "please union select test_user@demo.com")));
AjaxResult rawResult = controller.check("ak_test", "sk_test", wrap(rawReq));
assertEquals(200, rawResult.get("code"));
OpenApiGuardResponse rawResp = (OpenApiGuardResponse) rawResult.get("data");
assertTrue(rawResp.getAlerted());
assertEquals("BLOCK", rawResp.getDecision());
assertTrue(rawResp.getHits().stream().noneMatch(h -> "ACL_IP_WHITELIST".equals(h.getEventType())));
}
private Integer countAlertByRequestId(String requestId) {
return jdbcTemplate.queryForObject("select count(1) from d_log_alert_event where request_id = ?", Integer.class, requestId);
}
private OpenApiGuardCheckRequest wrap(OpenApiGuardRequest reqData) {
OpenApiGuardCheckRequest req = new OpenApiGuardCheckRequest();
req.setSourceIp(reqData.getSourceIp());
req.setPath(reqData.getPath());
req.setMethod(reqData.getMethod());
req.setInterfaceType(reqData.getInterfaceType());
req.setCallerId(reqData.getCallerId());
req.setReqData(toMap(reqData));
return req;
}
private Map<String, Object> toMap(OpenApiGuardRequest reqData) {
Map<String, Object> m = new LinkedHashMap<>();
putIfNotNull(m, "requestId", reqData.getRequestId());
putIfNotNull(m, "traceId", reqData.getTraceId());
putIfNotNull(m, "scopeCode", reqData.getScopeCode());
putIfNotNull(m, "interfaceType", reqData.getInterfaceType());
putIfNotNull(m, "path", reqData.getPath());
putIfNotNull(m, "method", reqData.getMethod());
putIfNotNull(m, "sourceIp", reqData.getSourceIp());
putIfNotNull(m, "callerId", reqData.getCallerId());
putIfNotNull(m, "stream", reqData.getStream());
putIfNotNull(m, "text", reqData.getText());
putIfNotNull(m, "messages", reqData.getMessages());
putIfNotNull(m, "files", reqData.getFiles());
putIfNotNull(m, "metadata", reqData.getMetadata());
putIfNotNull(m, "payload", reqData.getPayload());
if (reqData.getExtensions() != null && !reqData.getExtensions().isEmpty()) {
m.putAll(reqData.getExtensions());
}
return m;
}
private void putIfNotNull(Map<String, Object> map, String key, Object value) {
if (value != null) {
map.put(key, value);
}
}
private void initSchema() {
jdbcTemplate.execute("drop all objects");
jdbcTemplate.execute("create table d_sys_user (user_id varchar(64), user_name varchar(64), status char(1), del_flag char(1), api_key varchar(128), api_secret varchar(256))");
jdbcTemplate.execute("create table d_acl_ip_rule (id varchar(64), scope_code varchar(64), list_type varchar(16), ip_type varchar(16), ip_value varchar(255), priority int, status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_acl_endpoint_rule (id varchar(64), scope_code varchar(64), match_type varchar(16), uri_pattern varchar(512), methods varchar(64), status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_acl_custom_rule (id varchar(64), scope_code varchar(64), rule_code varchar(100), logic_op varchar(8), action varchar(20), priority int, status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_acl_custom_condition (id bigint generated by default as identity primary key, rule_id varchar(64), seq_no int, condition_expr varchar(1024), is_deleted int)");
jdbcTemplate.execute("create table d_attack_switch (id bigint generated by default as identity primary key, scope_code varchar(64), switch_key varchar(64), enabled smallint, is_deleted int)");
jdbcTemplate.execute("create table d_attack_rule (id varchar(64), scope_code varchar(64), rule_code varchar(100), category varchar(20), sub_type varchar(64), action varchar(20), status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_attack_signature (id varchar(64), scope_code varchar(64), sig_type varchar(20), sig_value varchar(1024), rule_code varchar(100), status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_log_attack_policy (id varchar(64), scope_code varchar(64), alert_level varchar(20), status varchar(20), update_time timestamp, is_deleted int)");
jdbcTemplate.execute("create table d_content_policy (id varchar(64), scope_code varchar(64), policy_code varchar(100), action varchar(20), status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_content_dlp_rule (id varchar(64), scope_code varchar(64), rule_code varchar(100), data_type varchar(64), action varchar(20), status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_content_mask_policy (id varchar(64), scope_code varchar(64), policy_code varchar(100), template_name varchar(128), action varchar(20), status varchar(20), is_deleted int)");
jdbcTemplate.execute("create table d_log_alert_event (id bigint generated by default as identity primary key, event_no varchar(64), request_id varchar(64), trace_id varchar(64), scope_code varchar(64), module_type varchar(20), event_type varchar(32), rule_type varchar(32), rule_id varchar(64), rule_code varchar(100), severity varchar(20), action_taken varchar(20), hit_message varchar(512), request_path varchar(1024), request_method varchar(16), source_ip varchar(45), caller_id varchar(128), is_stream smallint, alert_status varchar(20), occurred_at timestamp, partition_date date, create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, is_deleted int)");
jdbcTemplate.execute("create table d_log_alert_hit (id bigint generated by default as identity primary key, event_id bigint, hit_order int, hit_target varchar(32), hit_field varchar(255), hit_operator varchar(32), expected_value varchar(512), actual_value_preview varchar(512), confidence decimal(5,2), create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, is_deleted int)");
}
private void seedAuthUser() {
jdbcTemplate.update("insert into d_sys_user(user_id, user_name, status, del_flag, api_key, api_secret) values (?,?,?,?,?,?)",
"u1", "partnerA", "0", "0", "ak_test", "sk_test");
}
private void seedRules() {
jdbcTemplate.update("insert into d_acl_ip_rule(id, scope_code, list_type, ip_type, ip_value, priority, status, is_deleted) values (?,?,?,?,?,?,?,?)",
"ipw1", "GLOBAL", "WHITELIST", "SINGLE", "10.0.0.1", 1, "ENABLED", 0);
jdbcTemplate.update("insert into d_acl_endpoint_rule(id, scope_code, match_type, uri_pattern, methods, status, is_deleted) values (?,?,?,?,?,?,?)",
"ep1", "GLOBAL", "EXACT", "/api/session/run", "POST", "ENABLED", 0);
jdbcTemplate.update("insert into d_attack_switch(scope_code, switch_key, enabled, is_deleted) values (?,?,?,?)",
"GLOBAL", "INJECTION_SQL", 1, 0);
jdbcTemplate.update("insert into d_attack_rule(id, scope_code, rule_code, category, sub_type, action, status, is_deleted) values (?,?,?,?,?,?,?,?)",
"ar1", "GLOBAL", "ATTACK_SQL_1", "INJECTION", "SQL", "BLOCK", "ENABLED", 0);
jdbcTemplate.update("insert into d_attack_signature(id, scope_code, sig_type, sig_value, rule_code, status, is_deleted) values (?,?,?,?,?,?,?)",
"sig1", "GLOBAL", "REGEX", "union\\s+select", "ATTACK_SQL_1", "ENABLED", 0);
jdbcTemplate.update("insert into d_content_dlp_rule(id, scope_code, rule_code, data_type, action, status, is_deleted) values (?,?,?,?,?,?,?)",
"dlp1", "GLOBAL", "DLP_EMAIL_1", "EMAIL", "ALERT", "ENABLED", 0);
jdbcTemplate.update("insert into d_log_attack_policy(id, scope_code, alert_level, status, update_time, is_deleted) values (?,?,?,?,CURRENT_TIMESTAMP,?)",
"lp1", "GLOBAL", "HIGH", "ENABLED", 0);
}
}

View File

@ -60,13 +60,32 @@
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.llm.guard.system.LlmSystemApplication</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>repackage</goal>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>

View File

@ -0,0 +1,28 @@
package com.llm.guard.system.config;
import com.llm.guard.system.service.ISysUserService;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
@AllArgsConstructor
public class UserApiCredentialInitRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(UserApiCredentialInitRunner.class);
private final ISysUserService userService;
@Override
public void run(ApplicationArguments args) {
int count = userService.initUserApiCredentials();
if (count > 0) {
log.info("初始化用户开放接口密钥完成,补齐数量: {}", count);
} else {
log.info("用户开放接口密钥检查完成,无需补齐");
}
}
}

View File

@ -142,4 +142,21 @@ public interface SysUserMapper extends BaseMapper<SysUser>
* @return 结果
*/
public SysUser checkEmailUnique(String email);
/**
* 查询 apiKey/apiSecret 缺失用户
*
* @return 用户列表
*/
public List<SysUser> selectUsersWithoutApiCredentials();
/**
* 更新用户开放接口密钥
*
* @param userId 用户ID
* @param apiKey apiKey
* @param apiSecret apiSecret
* @return 结果
*/
public int updateApiCredentials(@Param("userId") String userId, @Param("apiKey") String apiKey, @Param("apiSecret") String apiSecret);
}

View File

@ -212,4 +212,11 @@ public interface ISysUserService extends IService<SysUser>
* @return 结果
*/
public String importUser(List<SysUser> userList, Boolean isUpdateSupport, String operName);
/**
* 初始化缺失的开放接口密钥
*
* @return 初始化数量
*/
public int initUserApiCredentials();
}

View File

@ -263,6 +263,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
public int insertUser(SysUser user)
{
fillUserIdIfEmpty(user);
ensureApiCredentialsIfBlank(user);
// 新增用户信息
int rows = userMapper.insertUser(user);
// 新增用户岗位关联
@ -282,6 +283,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
public boolean registerUser(SysUser user)
{
fillUserIdIfEmpty(user);
ensureApiCredentialsIfBlank(user);
return userMapper.insertUser(user) > 0;
}
@ -296,6 +298,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
public int updateUser(SysUser user)
{
String userId = user.getUserId();
handleApiCredentialsForUpdate(user);
// 删除用户与角色关联
userRoleMapper.deleteUserRoleByUserId(userId);
// 新增用户与角色管理
@ -522,6 +525,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
user.setPassword(SecurityUtils.encryptPassword(password));
user.setCreateBy(operName);
fillUserIdIfEmpty(user);
ensureApiCredentialsIfBlank(user);
userMapper.insertUser(user);
successNum++;
successMsg.append("<br/>" + successNum + "、账号 " + user.getUserName() + " 导入成功");
@ -565,6 +569,24 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return successMsg.toString();
}
@Override
@Transactional(rollbackFor = Exception.class)
public int initUserApiCredentials()
{
List<SysUser> users = userMapper.selectUsersWithoutApiCredentials();
if (CollectionUtils.isEmpty(users))
{
return 0;
}
int count = 0;
for (SysUser user : users)
{
regenerateApiCredentials(user);
count += userMapper.updateApiCredentials(user.getUserId(), user.getApiKey(), user.getApiSecret());
}
return count;
}
/**
* 为空时生成用户主键避免数据库默认值无法回填
*/
@ -575,4 +597,44 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
user.setUserId(IdUtils.randomUUID());
}
}
private void ensureApiCredentialsIfBlank(SysUser user)
{
if (StringUtils.isAnyBlank(user.getApiKey(), user.getApiSecret()))
{
regenerateApiCredentials(user);
}
}
private void handleApiCredentialsForUpdate(SysUser user)
{
boolean apiKeyProvided = user.getApiKey() != null;
boolean apiSecretProvided = user.getApiSecret() != null;
// 前端显式传空表示请求轮换密钥
if (apiKeyProvided || apiSecretProvided)
{
if (StringUtils.isAnyBlank(user.getApiKey(), user.getApiSecret()))
{
regenerateApiCredentials(user);
}
return;
}
// 未传密钥字段时保持原值不变仅当数据库原值为空时补生成
SysUser dbUser = userMapper.selectUserById(user.getUserId());
if (dbUser == null || StringUtils.isAnyBlank(dbUser.getApiKey(), dbUser.getApiSecret()))
{
regenerateApiCredentials(user);
return;
}
user.setApiKey(dbUser.getApiKey());
user.setApiSecret(dbUser.getApiSecret());
}
private void regenerateApiCredentials(SysUser user)
{
user.setApiKey("ak_" + IdUtils.fastSimpleUUID());
user.setApiSecret("sk_" + IdUtils.fastSimpleUUID() + IdUtils.fastSimpleUUID());
}
}

View File

@ -14,6 +14,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="sex" column="sex" />
<result property="avatar" column="avatar" />
<result property="password" column="password" />
<result property="apiKey" column="api_key" />
<result property="apiSecret" column="api_secret" />
<result property="status" column="status" />
<result property="delFlag" column="del_flag" />
<result property="loginIp" column="login_ip" />
@ -48,7 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectUserVo">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark,
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.api_key, u.api_secret, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark,
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
from d_sys_user u
@ -58,7 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</sql>
<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from d_sys_user u
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.api_key, u.api_secret, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from d_sys_user u
left join d_sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<if test="userId != null and userId != ''">
@ -142,6 +144,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="checkEmailUnique" parameterType="String" resultMap="SysUserResult">
select user_id, email from d_sys_user where email = #{email} and del_flag = '0' limit 1
</select>
<select id="selectUsersWithoutApiCredentials" resultMap="SysUserResult">
select user_id, user_name, api_key, api_secret
from d_sys_user
where del_flag = '0'
and (api_key is null or api_key = '' or api_secret is null or api_secret = '')
</select>
<insert id="insertUser" parameterType="SysUser" useGeneratedKeys="true" keyProperty="userId">
insert into d_sys_user(
@ -154,6 +163,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="phonenumber != null and phonenumber != ''">phonenumber,</if>
<if test="sex != null and sex != ''">sex,</if>
<if test="password != null and password != ''">password,</if>
<if test="apiKey != null and apiKey != ''">api_key,</if>
<if test="apiSecret != null and apiSecret != ''">api_secret,</if>
<if test="status != null and status != ''">status,</if>
<if test="pwdUpdateDate != null">pwd_update_date,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
@ -169,6 +180,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="phonenumber != null and phonenumber != ''">#{phonenumber},</if>
<if test="sex != null and sex != ''">#{sex},</if>
<if test="password != null and password != ''">#{password},</if>
<if test="apiKey != null and apiKey != ''">#{apiKey},</if>
<if test="apiSecret != null and apiSecret != ''">#{apiSecret},</if>
<if test="status != null and status != ''">#{status},</if>
<if test="pwdUpdateDate != null">#{pwdUpdateDate},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
@ -187,6 +200,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="sex != null and sex != ''">sex = #{sex},</if>
<if test="avatar != null and avatar != ''">avatar = #{avatar},</if>
<if test="password != null and password != ''">password = #{password},</if>
<if test="apiKey != null and apiKey != ''">api_key = #{apiKey},</if>
<if test="apiSecret != null and apiSecret != ''">api_secret = #{apiSecret},</if>
<if test="status != null and status != ''">status = #{status},</if>
<if test="loginIp != null and loginIp != ''">login_ip = #{loginIp},</if>
<if test="loginDate != null">login_date = #{loginDate},</if>
@ -212,6 +227,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="resetUserPwd" parameterType="SysUser">
update d_sys_user set pwd_update_date = now(), password = #{password}, update_time = now() where user_id = #{userId}
</update>
<update id="updateApiCredentials">
update d_sys_user
set api_key = #{apiKey},
api_secret = #{apiSecret},
update_time = now()
where user_id = #{userId}
</update>
<delete id="deleteUserById" parameterType="String">
update d_sys_user set del_flag = '2' where user_id = #{userId}
@ -224,4 +247,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</delete>
</mapper>
</mapper>

View File

@ -11,6 +11,7 @@
<modules>
<module>llm-guard-system</module>
<module>llm-guard-biz</module>
<module>llm-guard-open-api</module>
</modules>
<artifactId>llm-guard-modules</artifactId>