feat: enhance coding harness, model security and system features

This commit is contained in:
ageerle
2026-09-08 23:33:29 +08:00
parent d4ed2b8672
commit bcff4fd9e3
259 changed files with 15183 additions and 2713 deletions

View File

@@ -68,6 +68,12 @@
<artifactId>swagger-annotations</artifactId>
<version>${swagger-annotations.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,11 +1,16 @@
package org.ruoyi.common.chat.domain.bo.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.entity.chat.ChatModel;
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
import org.ruoyi.common.chat.security.ChatModelSecretReference;
import org.ruoyi.common.core.validate.AddGroup;
import org.ruoyi.common.core.validate.EditGroup;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
@@ -63,11 +68,21 @@ public class ChatModelBo extends BaseEntity {
/**
* 请求地址
*/
@NotBlank(message = "请求地址不能为空", groups = { AddGroup.class, EditGroup.class })
@Pattern(regexp = ChatModelCredentialPolicy.HTTPS_API_HOST_REGEXP,
message = "请求地址必须是无凭据、查询参数和片段的 HTTPS 地址",
groups = { AddGroup.class, EditGroup.class })
private String apiHost;
/**
* 密钥
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
message = "密钥必须使用受信任的环境变量引用",
groups = { AddGroup.class, EditGroup.class })
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
/**

View File

@@ -0,0 +1,11 @@
package org.ruoyi.common.chat.domain.bo.chat;
import lombok.Data;
/** Minimal, non-secret query contract for user-facing model selection. */
@Data
public class ChatModelSelectQuery {
/** Optional model category; defaults to chat at the controller boundary. */
private String category;
}

View File

@@ -1,8 +1,12 @@
package org.ruoyi.common.chat.domain.bo.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.security.ChatModelSecretReference;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
/**
@@ -24,7 +28,12 @@ public class ModelBatchKeyBo extends BaseEntity {
/**
* 密钥
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@NotBlank(message = "密钥不能为空")
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
message = "密钥必须使用受信任的环境变量引用")
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
}

View File

@@ -75,6 +75,19 @@ public class ChatRequest {
*/
private Boolean enableThinking = false;
/**
* 是否回放上下文中已经存在的模型思考内容。
* 仅供需要在非思考动作轮中延续 DeepSeek 工具协议的内部调用使用。
*/
private Boolean replayThinking = false;
/**
* Doubao-Seed-Evolving 思考等级(reasoning_effort)。
* 取值 none/minimal/low/medium/high/xhigh/max;null 表示不发送 Doubao 专属参数。
* 仅在 custom_api 供应商承载 Doubao 模型时使用,其他模型忽略此字段。
*/
private String reasoningEffort;
/**
* 对话模型详情
*/

View File

@@ -0,0 +1,40 @@
package org.ruoyi.common.chat.domain.vo.chat;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* Public model option exposed to clients that can select a chat model.
*
* <p>This deliberately contains only the fields required to identify and display a model.
* Operational configuration, including endpoints and credentials, remains server-side.</p>
*/
@Data
public class ChatModelSelectVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** Model configuration identifier. */
private Long id;
/** Configured model name. */
private String modelName;
/** User-facing model description; clients can fall back to modelName when blank. */
private String modelDescribe;
/** Provider identifier used for display. */
private String providerCode;
public static ChatModelSelectVo from(ChatModelVo source) {
ChatModelSelectVo target = new ChatModelSelectVo();
target.setId(source.getId());
target.setModelName(source.getModelName());
target.setModelDescribe(source.getModelDescribe());
target.setProviderCode(source.getProviderCode());
return target;
}
}

View File

@@ -3,9 +3,13 @@ package org.ruoyi.common.chat.domain.vo.chat;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.entity.chat.ChatModel;
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
import java.io.Serial;
import java.io.Serializable;
@@ -18,7 +22,7 @@ import java.io.Serializable;
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = ChatModel.class)
@AutoMapper(target = ChatModel.class, convertGenerate = false)
public class ChatModelVo implements Serializable {
@Serial
@@ -74,11 +78,29 @@ public class ChatModelVo implements Serializable {
private String apiHost;
/**
* 密钥
* 密钥仅用于服务端模型调用和写入配置。任何读取接口、日志字符串或 Excel 导出
* 都不得把它带出服务端边界。
*/
@ExcelProperty(value = "密钥")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
/** Returns the stored configuration reference without touching the process environment. */
public String getApiKey() {
return apiKey;
}
/**
* Resolves the reference only after binding it to both the consuming provider and this row's
* effective provider, model and endpoint. Non-allowlisted, misrouted, or legacy rows fail
* closed before the environment is read.
*/
public String resolveApiKeyForConfiguredEndpoint(String consumingProviderCode) {
return ChatModelCredentialPolicy.resolveApiKeyForUse(
consumingProviderCode, providerCode, modelName, apiHost, apiKey);
}
/**
* 备注
*/

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.tenant.core.TenantEntity;
import java.io.Serial;
@@ -67,6 +68,8 @@ public class ChatModel extends TenantEntity {
/**
* 密钥
*/
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
/**

View File

@@ -0,0 +1,185 @@
package org.ruoyi.common.chat.security;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
/** Security policy binding model credential references to trusted provider endpoints. */
public final class ChatModelCredentialPolicy {
public static final String HTTPS_API_HOST_REGEXP =
"^https://(?![^/?#]*@)[^\\s/?#]+(?::[0-9]{1,5})?(?:/[^\\s?#]*)?$";
public static final String DEEPSEEK_PROVIDER = "deepseek";
public static final String DEEPSEEK_API_HOST = "https://api.deepseek.com";
public static final String PPIO_PROVIDER = "ppio";
public static final String PPIO_API_HOST = "https://api.ppio.com/openai/v1";
private static final Set<String> PPIO_API_HOSTS = Set.of(
PPIO_API_HOST, PPIO_API_HOST + "/",
"https://api.ppio.com/openai", "https://api.ppio.com/openai/"
);
private static final Set<String> DEEPSEEK_MODELS = Set.of(
"deepseek-v4-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-vision-exp"
);
private ChatModelCredentialPolicy() {
}
/**
* Resolves a provider credential only after the effective provider/model/endpoint/reference
* tuple passes the same allowlist used at the persistence boundary.
*/
public static String resolveApiKeyForUse(String consumingProviderCode,
String providerCode,
String modelName,
String apiHost,
String apiKey) {
return resolveApiKeyForUse(consumingProviderCode, providerCode, modelName, apiHost,
apiKey, System::getenv);
}
static String resolveApiKeyForUse(String consumingProviderCode,
String providerCode,
String modelName,
String apiHost,
String apiKey,
Function<String, String> environment) {
if (!Objects.equals(consumingProviderCode, providerCode)) {
throw new IllegalArgumentException(
"Credential consumer does not match the configured provider");
}
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey, environment);
} else {
requireTrustedConfiguration(providerCode, modelName, apiHost, apiKey);
}
return ChatModelSecretReference.resolveAllowlistedReference(apiKey,
Objects.requireNonNull(environment, "environment"));
}
/** Validates the effective row which will remain after an insert or partial update. */
public static void requirePersistableConfiguration(String providerCode,
String modelName,
String apiHost,
String apiKey) {
requireSecureApiHost(apiHost);
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
return;
}
if (PPIO_PROVIDER.equals(providerCode)) {
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
return;
}
if (apiKey != null) {
ChatModelSecretReference.requirePersistableReference(apiKey);
if (!isDeepSeekConfiguration(providerCode, modelName)) {
throw new IllegalArgumentException(
"Model API key reference is not allowed for this provider");
}
}
if (isDeepSeekConfiguration(providerCode, modelName)) {
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
}
}
/** Re-validates the configuration immediately before a DeepSeek provider client is built. */
public static void requireDeepSeekConfiguration(String providerCode,
String modelName,
String apiHost,
String apiKey) {
if (!DEEPSEEK_PROVIDER.equals(providerCode)) {
throw new IllegalArgumentException("DeepSeek model provider is not trusted");
}
if (!DEEPSEEK_MODELS.contains(modelName)) {
throw new IllegalArgumentException("DeepSeek model is not allowlisted");
}
requireSecureApiHost(apiHost);
if (!DEEPSEEK_API_HOST.equals(apiHost)) {
throw new IllegalArgumentException("DeepSeek API host is not allowlisted");
}
if (apiKey == null) {
throw new IllegalArgumentException("DeepSeek API key reference is required");
}
requireProviderReference(providerCode, apiKey);
}
/** Binds each provider to its own environment variable, including batch credential updates. */
public static void requireProviderReference(String providerCode, String apiKey) {
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireReference(providerCode, apiKey);
return;
}
String expectedReference;
if (DEEPSEEK_PROVIDER.equals(providerCode)) {
expectedReference = "env:DEEPSEEK_API_KEY";
} else if (PPIO_PROVIDER.equals(providerCode)) {
expectedReference = "env:PPIO_API_KEY";
} else {
throw new IllegalArgumentException("Model credential provider is not allowlisted");
}
ChatModelSecretReference.requirePersistableReference(apiKey);
if (!expectedReference.equals(apiKey)) {
throw new IllegalArgumentException("Model API key reference does not match the provider");
}
}
/** Validates credential use without allowing another adapter to consume a provider's key. */
public static void requireTrustedConfiguration(String providerCode, String modelName,
String apiHost, String apiKey) {
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
} else if (PPIO_PROVIDER.equals(providerCode)) {
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
} else {
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
}
}
/** Validates PPIO and returns the base URL expected by the OpenAI Chat Completions client. */
public static String requirePpioConfiguration(String providerCode, String modelName,
String apiHost, String apiKey) {
if (!PPIO_PROVIDER.equals(providerCode)) {
throw new IllegalArgumentException("PPIO model provider is not trusted");
}
// PPIO hosts models from multiple vendors; use the full model ID from its catalog.
if (modelName == null || !modelName.matches("[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}")) {
throw new IllegalArgumentException("PPIO model ID is invalid");
}
requireSecureApiHost(apiHost);
if (!PPIO_API_HOSTS.contains(apiHost)) {
throw new IllegalArgumentException("PPIO API host is not allowlisted");
}
requireProviderReference(providerCode, apiKey);
return PPIO_API_HOST;
}
public static String requireSecureApiHost(String apiHost) {
if (apiHost == null || apiHost.isBlank()) {
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
}
final URI uri;
try {
uri = new URI(apiHost);
} catch (URISyntaxException invalid) {
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
}
if (!uri.isAbsolute() || !"https".equals(uri.getScheme()) || uri.getHost() == null
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null
|| uri.getPort() > 65535) {
throw new IllegalArgumentException(
"Model API host must be HTTPS without userinfo, query, or fragment");
}
return apiHost;
}
public static boolean isDeepSeekConfiguration(String providerCode, String modelName) {
return DEEPSEEK_PROVIDER.equals(providerCode) || DEEPSEEK_MODELS.contains(modelName);
}
}

View File

@@ -0,0 +1,71 @@
package org.ruoyi.common.chat.security;
import java.util.Objects;
import java.util.function.Function;
import java.util.regex.Pattern;
/** Resolves an explicit {@code env:NAME} model credential without exposing it through DTO JSON. */
public final class ChatModelSecretReference {
public static final String ENV_REFERENCE_REGEXP =
"^env:(?:(?:DEEPSEEK|PPIO)_API_KEY|" + CustomApiCredentialPolicy.REFERENCE_PATTERN + ")$";
private static final String ENV_PREFIX = "env:";
private static final Pattern ENV_REFERENCE = Pattern.compile(ENV_REFERENCE_REGEXP);
private ChatModelSecretReference() {
}
/**
* Package-private final resolution step. Callers must first bind provider, model and endpoint
* through {@link ChatModelCredentialPolicy}; exposing a context-free resolver would recreate a
* credential-confusion channel.
*/
static String resolveAllowlistedReference(String configuredValue,
Function<String, String> environment) {
String reference = requirePersistableReference(configuredValue);
if (reference == null) {
return null;
}
String variable = reference.substring(ENV_PREFIX.length());
String resolved = Objects.requireNonNull(environment, "environment").apply(variable);
if (resolved == null || resolved.isBlank()) {
throw new IllegalStateException(
"Required model API key environment variable is not configured: " + variable);
}
return resolved;
}
/**
* Validates the only credential representation that may cross a persistence boundary.
* A {@code null} value means that an update did not request a credential change.
*/
public static String requirePersistableReference(String configuredValue) {
if (configuredValue == null) {
return null;
}
if (!ENV_REFERENCE.matcher(configuredValue).matches()) {
throw new IllegalArgumentException(
"Invalid model API key reference; reference is not allowlisted");
}
return configuredValue;
}
/**
* Narrow compatibility hook for an in-place legacy migration. It is deliberately package
* private and requires an audit callback which never receives the credential value. Normal
* provider resolution and all persistence paths must use the strict methods above.
*/
static String resolveForLegacyMigration(String configuredValue,
Function<String, String> environment,
Runnable legacyPlaintextAudit) {
if (configuredValue == null || configuredValue.startsWith(ENV_PREFIX)) {
return resolveAllowlistedReference(configuredValue, environment);
}
if (configuredValue.isBlank()) {
throw new IllegalArgumentException("Legacy model API key cannot be blank");
}
Objects.requireNonNull(legacyPlaintextAudit, "legacyPlaintextAudit").run();
return configuredValue;
}
}

View File

@@ -0,0 +1,80 @@
package org.ruoyi.common.chat.security;
import java.net.URI;
import java.util.function.Function;
import java.util.regex.Pattern;
/** Binds configurable OpenAI/Anthropic endpoints to dedicated deployment credentials. */
public final class CustomApiCredentialPolicy {
public static final String OPENAI_PROVIDER = "custom_api";
public static final String ANTHROPIC_PROVIDER = "custom_anthropic";
public static final String REFERENCE_PATTERN =
"CUSTOM_(?:OPENAI|ANTHROPIC)(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
private CustomApiCredentialPolicy() {
}
public static boolean isCustomProvider(String providerCode) {
return OPENAI_PROVIDER.equals(providerCode) || ANTHROPIC_PROVIDER.equals(providerCode);
}
public static void requireReference(String providerCode, String reference) {
String protocol;
if (OPENAI_PROVIDER.equals(providerCode)) {
protocol = "OPENAI";
} else if (ANTHROPIC_PROVIDER.equals(providerCode)) {
protocol = "ANTHROPIC";
} else {
throw new IllegalArgumentException("不支持的自定义厂商协议");
}
String pattern = "env:CUSTOM_" + protocol + "(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
if (reference == null || !Pattern.matches(pattern, reference)) {
throw new IllegalArgumentException("自定义厂商密钥必须使用与协议匹配的环境变量引用");
}
}
public static String requireConfiguration(String providerCode, String modelName,
String apiHost, String reference) {
return requireConfiguration(providerCode, modelName, apiHost, reference, System::getenv);
}
static String requireConfiguration(String providerCode, String modelName, String apiHost,
String reference, Function<String, String> environment) {
requireReference(providerCode, reference);
if (modelName == null || modelName.isBlank()) {
throw new IllegalArgumentException("自定义厂商的模型名称不能为空");
}
String baseUrl = normalizeBaseUrl(providerCode, apiHost);
String keyVariable = reference.substring("env:".length());
String hostVariable = keyVariable.substring(0, keyVariable.length() - "_API_KEY".length()) + "_BASE_URL";
String configuredHost = environment.apply(hostVariable);
if (configuredHost == null || configuredHost.isBlank()) {
throw new IllegalArgumentException("请先在后端配置自定义厂商地址环境变量:" + hostVariable);
}
if (!baseUrl.equals(normalizeBaseUrl(providerCode, configuredHost))) {
throw new IllegalArgumentException("模型请求地址与该密钥绑定的自定义厂商地址不一致");
}
return baseUrl;
}
/** Accepts a base URL or the selected protocol's complete chat endpoint. */
public static String normalizeBaseUrl(String providerCode, String apiHost) {
if (!isCustomProvider(providerCode)) {
throw new IllegalArgumentException("不支持的自定义厂商协议");
}
ChatModelCredentialPolicy.requireSecureApiHost(apiHost);
String normalized = apiHost.replaceAll("/+$", "");
String suffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/messages" : "/chat/completions";
String otherSuffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/chat/completions" : "/messages";
if (normalized.endsWith(otherSuffix)) {
throw new IllegalArgumentException("请求地址与所选自定义厂商协议不匹配");
}
if (normalized.endsWith(suffix)) {
normalized = normalized.substring(0, normalized.length() - suffix.length());
} else if (URI.create(normalized).getPath().isEmpty()) {
normalized += "/v1";
}
return normalized;
}
}

View File

@@ -49,6 +49,9 @@ public interface IChatModelService {
*/
List<ChatModelVo> queryList(ChatModelBo bo);
/** 查询当前租户中厂商已启用的模型,供用户选择或业务调用。 */
List<ChatModelVo> queryAvailableList(ChatModelBo bo);
/**
* 新增模型管理
*