feat: 收口知识库分享链路

- 新增 shareKey 单参数 URL 分享页与失效页

- 新增知识库分享后端鉴权、审计与迁移脚本

- 在访问令牌中增加知识库分享授权入口
This commit is contained in:
2026-04-13 14:44:31 +08:00
parent 8cfe5400fe
commit 31a755a8bc
57 changed files with 5158 additions and 143 deletions

View File

@@ -0,0 +1,15 @@
package tech.easyflow.ai.constants;
/**
* 知识库分享错误码定义。
*/
public final class KnowledgeShareErrorCode {
public static final int KNOWLEDGE_SHARE_EXPIRED = 4601;
public static final int KNOWLEDGE_SHARE_INVALID = 4602;
public static final int KNOWLEDGE_SHARE_FORBIDDEN = 4603;
private KnowledgeShareErrorCode() {
}
}

View File

@@ -0,0 +1,40 @@
package tech.easyflow.ai.dto;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Set;
/**
* API 分享授权请求。
*/
public class KnowledgeShareApiGrantRequest implements Serializable {
private BigInteger apiKeyId;
private BigInteger knowledgeId;
private Set<String> actionScopes;
public BigInteger getApiKeyId() {
return apiKeyId;
}
public void setApiKeyId(BigInteger apiKeyId) {
this.apiKeyId = apiKeyId;
}
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public Set<String> getActionScopes() {
return actionScopes;
}
public void setActionScopes(Set<String> actionScopes) {
this.actionScopes = actionScopes;
}
}

View File

@@ -0,0 +1,75 @@
package tech.easyflow.ai.dto;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 分享页受限配置更新请求。
*/
public class KnowledgeShareLimitedConfigRequest implements Serializable {
private BigInteger knowledgeId;
private BigInteger vectorEmbedModelId;
private BigInteger rerankModelId;
private Boolean rerankEnable;
private Integer docRecallMaxNum;
private Double simThreshold;
private Boolean rebuildVectors;
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public BigInteger getVectorEmbedModelId() {
return vectorEmbedModelId;
}
public void setVectorEmbedModelId(BigInteger vectorEmbedModelId) {
this.vectorEmbedModelId = vectorEmbedModelId;
}
public BigInteger getRerankModelId() {
return rerankModelId;
}
public void setRerankModelId(BigInteger rerankModelId) {
this.rerankModelId = rerankModelId;
}
public Boolean getRerankEnable() {
return rerankEnable;
}
public void setRerankEnable(Boolean rerankEnable) {
this.rerankEnable = rerankEnable;
}
public Integer getDocRecallMaxNum() {
return docRecallMaxNum;
}
public void setDocRecallMaxNum(Integer docRecallMaxNum) {
this.docRecallMaxNum = docRecallMaxNum;
}
public Double getSimThreshold() {
return simThreshold;
}
public void setSimThreshold(Double simThreshold) {
this.simThreshold = simThreshold;
}
public Boolean getRebuildVectors() {
return rebuildVectors;
}
public void setRebuildVectors(Boolean rebuildVectors) {
this.rebuildVectors = rebuildVectors;
}
}

View File

@@ -0,0 +1,69 @@
package tech.easyflow.ai.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Table;
import tech.easyflow.ai.entity.base.KnowledgeShareBase;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 知识库分享记录。
*/
@Table("tb_knowledge_share")
public class KnowledgeShare extends KnowledgeShareBase {
@Column(ignore = true)
private String shareUrl;
/**
* 解析授权范围。
*
* @return 授权范围集合
*/
public Set<String> getPermissionScopeSet() {
if (getPermissionSet() == null || getPermissionSet().isBlank()) {
return Collections.emptySet();
}
Set<String> scopes = new LinkedHashSet<>();
String[] segments = getPermissionSet().split(",");
for (String segment : segments) {
if (segment == null || segment.isBlank()) {
continue;
}
scopes.add(segment.trim().toUpperCase());
}
return scopes;
}
/**
* 写入授权范围。
*
* @param scopes 授权范围集合
*/
public void setPermissionScopes(Iterable<String> scopes) {
StringBuilder builder = new StringBuilder();
if (scopes != null) {
for (String scope : scopes) {
if (scope == null || scope.isBlank()) {
continue;
}
if (builder.length() > 0) {
builder.append(',');
}
builder.append(scope.trim().toUpperCase());
}
}
setPermissionSet(builder.toString());
}
public String getShareUrl() {
return shareUrl;
}
public void setShareUrl(String shareUrl) {
this.shareUrl = shareUrl;
}
}

View File

@@ -0,0 +1,160 @@
package tech.easyflow.ai.entity.base;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 知识库分享记录基础字段。
*/
public class KnowledgeShareBase implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "ID")
private BigInteger id;
@Column(comment = "知识库ID")
private BigInteger knowledgeId;
@Column(comment = "分享类型")
private String shareType;
@Column(comment = "分享访问密钥哈希")
private String shareKeyHash;
@Column(comment = "分享状态")
private String status;
@Column(comment = "授权范围")
private String permissionSet;
@Column(comment = "过期时间")
private Date expiresAt;
@Column(tenantId = true, comment = "租户ID")
private BigInteger tenantId;
@Column(comment = "部门ID")
private BigInteger deptId;
@Column(comment = "创建时间")
private Date created;
@Column(comment = "创建人")
private BigInteger createdBy;
@Column(comment = "修改时间")
private Date modified;
@Column(comment = "修改人")
private BigInteger modifiedBy;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public String getShareType() {
return shareType;
}
public void setShareType(String shareType) {
this.shareType = shareType;
}
public String getShareKeyHash() {
return shareKeyHash;
}
public void setShareKeyHash(String shareKeyHash) {
this.shareKeyHash = shareKeyHash;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getPermissionSet() {
return permissionSet;
}
public void setPermissionSet(String permissionSet) {
this.permissionSet = permissionSet;
}
public Date getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Date expiresAt) {
this.expiresAt = expiresAt;
}
public BigInteger getTenantId() {
return tenantId;
}
public void setTenantId(BigInteger tenantId) {
this.tenantId = tenantId;
}
public BigInteger getDeptId() {
return deptId;
}
public void setDeptId(BigInteger deptId) {
this.deptId = deptId;
}
public Date getCreated() {
return created;
}
public void setCreated(Date created) {
this.created = created;
}
public BigInteger getCreatedBy() {
return createdBy;
}
public void setCreatedBy(BigInteger createdBy) {
this.createdBy = createdBy;
}
public Date getModified() {
return modified;
}
public void setModified(Date modified) {
this.modified = modified;
}
public BigInteger getModifiedBy() {
return modifiedBy;
}
public void setModifiedBy(BigInteger modifiedBy) {
this.modifiedBy = modifiedBy;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.ai.enums;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 知识库分享动作范围。
*/
public enum KnowledgeShareActionScope {
VIEW,
SEARCH,
CONTENT_CREATE,
CONTENT_UPDATE,
CONTENT_DELETE,
IMPORT_EXPORT,
CONFIG_UPDATE;
/**
* 解析前端提交的动作范围集合。
*
* @param values 原始动作范围
* @return 规范化后的动作范围集合
*/
public static Set<String> normalize(Iterable<String> values) {
Set<String> scopes = new LinkedHashSet<>();
if (values == null) {
return scopes;
}
for (String value : values) {
if (value == null || value.isBlank()) {
continue;
}
scopes.add(KnowledgeShareActionScope.valueOf(value.trim().toUpperCase()).name());
}
return scopes;
}
/**
* 获取默认 URL 分享授权范围。
*
* @return 默认授权范围
*/
public static Set<String> defaultUrlScopes() {
return new LinkedHashSet<>(Arrays.asList(
VIEW.name(),
SEARCH.name(),
CONTENT_CREATE.name(),
CONTENT_UPDATE.name(),
CONTENT_DELETE.name(),
IMPORT_EXPORT.name(),
CONFIG_UPDATE.name()
));
}
/**
* 获取默认 API 分享授权范围。
*
* <p>产品上固定开放查看、检索、新增、更新、导入导出,不提供删除能力。</p>
*
* @return 默认 API 授权范围
*/
public static Set<String> defaultApiScopes() {
return new LinkedHashSet<>(Arrays.asList(
VIEW.name(),
SEARCH.name(),
CONTENT_CREATE.name(),
CONTENT_UPDATE.name(),
IMPORT_EXPORT.name()
));
}
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.ai.enums;
/**
* 知识库分享状态。
*/
public enum KnowledgeShareStatus {
ENABLED,
DISABLED,
REVOKED
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.ai.enums;
/**
* 知识库分享类型。
*/
public enum KnowledgeShareType {
URL
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.ai.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.ai.entity.KnowledgeShare;
/**
* 知识库分享记录映射层。
*/
public interface KnowledgeShareMapper extends BaseMapper<KnowledgeShare> {
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.ai.service;
import java.math.BigInteger;
/**
* 知识库向量重建服务。
*/
public interface KnowledgeEmbeddingService {
/**
* 按知识库重建向量数据。
*
* @param knowledgeId 知识库 ID
*/
void rebuildKnowledgeVectors(BigInteger knowledgeId);
}

View File

@@ -0,0 +1,22 @@
package tech.easyflow.ai.service;
import java.math.BigInteger;
import java.util.Map;
/**
* 知识库分享审计服务。
*/
public interface KnowledgeShareAuditService {
/**
* 记录审计日志。
*
* @param accountId 操作人
* @param actionName 操作名称
* @param actionType 操作类型
* @param actionUrl 操作地址
* @param detail 扩展上下文
*/
void log(BigInteger accountId, String actionName, String actionType, String actionUrl, Map<String, Object> detail);
}

View File

@@ -0,0 +1,39 @@
package tech.easyflow.ai.service;
import java.math.BigInteger;
import java.util.Set;
/**
* 知识库分享权限服务。
*/
public interface KnowledgeSharePermissionService {
/**
* 为系统访问令牌授予知识库 API 分享权限。
*
* @param apiKeyId 系统访问令牌 ID
* @param knowledgeId 知识库 ID
* @param actionScopes 动作范围
*/
void grantApiShare(BigInteger apiKeyId, BigInteger knowledgeId, Set<String> actionScopes);
/**
* 按访问令牌维度开启或关闭知识库 API 分享授权。
*
* <p>产品固定为全量知识库的非删除范围,不向前端暴露动作粒度。</p>
*
* @param apiKeyId 系统访问令牌 ID
* @param enabled 是否启用知识库分享授权
*/
void replaceApiShareEnabled(BigInteger apiKeyId, boolean enabled);
/**
* 断言当前令牌具备知识库分享权限。
*
* @param apiKeyId 系统访问令牌 ID
* @param requestUri 请求地址
* @param knowledgeId 知识库 ID
* @param actionScope 动作范围
*/
void assertApiShare(BigInteger apiKeyId, String requestUri, BigInteger knowledgeId, String actionScope);
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.ai.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.KnowledgeShare;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
import java.math.BigInteger;
import java.util.Set;
/**
* 知识库分享服务。
*/
public interface KnowledgeShareService extends IService<KnowledgeShare> {
/**
* 创建 URL 分享。
*
* @param knowledgeId 目标知识库
* @param tenantId 租户 ID
* @param deptId 部门 ID
* @param operatorId 操作人
* @param baseUrl 分享页基础 URL
* @param permissionScopes 授权范围
* @return 创建结果
*/
KnowledgeShareUrlCreateResult createUrlShare(
BigInteger knowledgeId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl,
Set<String> permissionScopes
);
/**
* 校验 URL 分享并返回上下文。
*
* @param shareKey 分享访问密钥
* @return 鉴权上下文
*/
KnowledgeShareAuthContext validateUrlShare(String shareKey);
/**
* 断言 URL 分享允许当前动作。
*
* @param shareKey 分享访问密钥
* @param knowledgeId 知识库 ID
* @param actionScope 动作范围
* @return 鉴权上下文
*/
KnowledgeShareAuthContext assertUrlShareAccess(
String shareKey,
BigInteger knowledgeId,
String actionScope
);
}

View File

@@ -0,0 +1,156 @@
package tech.easyflow.ai.service.impl;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.FaqItemService;
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 知识库向量重建服务实现。
*/
@Service
public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService {
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private DocumentChunkService documentChunkService;
@Resource
private FaqItemService faqItemService;
@Resource
private ModelService modelService;
@Override
public void rebuildKnowledgeVectors(BigInteger knowledgeId) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
throw new BusinessException("知识库没有配置向量库");
}
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
throw new BusinessException("知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions storeOptions = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
storeOptions.setIndexName(knowledge.getVectorStoreCollection());
if (knowledge.isFaqCollection()) {
rebuildFaqVectors(knowledge, documentStore, storeOptions, embeddingModel);
return;
}
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
}
private void rebuildDocumentVectors(
DocumentCollection knowledge,
DocumentStore documentStore,
StoreOptions storeOptions,
EmbeddingModel embeddingModel
) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(DocumentChunk::getDocumentCollectionId, knowledge.getId())
.orderBy("sorting asc");
List<DocumentChunk> chunks = documentChunkService.list(wrapper);
List<BigInteger> ids = new ArrayList<>();
List<com.easyagents.core.document.Document> documents = new ArrayList<>();
for (DocumentChunk chunk : chunks) {
ids.add(chunk.getId());
com.easyagents.core.document.Document document = com.easyagents.core.document.Document.of(chunk.getContent());
document.setId(chunk.getId());
documents.add(document);
}
rewriteStore(documentStore, storeOptions, ids, documents);
updateKnowledgeEmbeddingState(knowledge, embeddingModel);
}
private void rebuildFaqVectors(
DocumentCollection knowledge,
DocumentStore documentStore,
StoreOptions storeOptions,
EmbeddingModel embeddingModel
) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, knowledge.getId())
.orderBy("order_no asc");
List<FaqItem> faqItems = faqItemService.list(wrapper);
List<BigInteger> ids = new ArrayList<>();
List<com.easyagents.core.document.Document> documents = new ArrayList<>();
for (FaqItem faqItem : faqItems) {
ids.add(faqItem.getId());
StringBuilder content = new StringBuilder();
content.append("问题:").append(faqItem.getQuestion());
if (faqItem.getAnswerText() != null && !faqItem.getAnswerText().isBlank()) {
content.append("\n答案").append(faqItem.getAnswerText());
}
com.easyagents.core.document.Document document =
com.easyagents.core.document.Document.of(content.toString());
document.setId(faqItem.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("question", faqItem.getQuestion());
metadata.put("answerText", faqItem.getAnswerText());
metadata.put("categoryId", faqItem.getCategoryId());
document.setMetadataMap(metadata);
documents.add(document);
}
rewriteStore(documentStore, storeOptions, ids, documents);
updateKnowledgeEmbeddingState(knowledge, embeddingModel);
}
private void rewriteStore(
DocumentStore documentStore,
StoreOptions storeOptions,
List<BigInteger> ids,
List<com.easyagents.core.document.Document> documents
) {
if (!ids.isEmpty()) {
documentStore.delete(ids, storeOptions);
}
if (documents.isEmpty()) {
return;
}
StoreResult result = documentStore.store(documents, storeOptions);
if (result == null || !result.isSuccess()) {
throw new BusinessException("知识库向量重建失败");
}
}
private void updateKnowledgeEmbeddingState(DocumentCollection knowledge, EmbeddingModel embeddingModel) {
DocumentCollection update = new DocumentCollection();
update.setId(knowledge.getId());
Map<String, Object> options = knowledge.getOptions() == null
? new HashMap<>()
: new HashMap<>(knowledge.getOptions());
options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, false);
update.setOptions(options);
if (knowledge.getDimensionOfVectorModel() == null) {
update.setDimensionOfVectorModel(Model.getEmbeddingDimension(embeddingModel));
}
documentCollectionService.updateById(update);
}
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.ai.service.impl;
import com.alibaba.fastjson2.JSON;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.system.entity.SysLog;
import tech.easyflow.system.service.SysLogService;
import javax.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import java.math.BigInteger;
import java.util.Date;
import java.util.Map;
/**
* 知识库分享审计服务实现。
*/
@Service
public class KnowledgeShareAuditServiceImpl implements KnowledgeShareAuditService {
@Resource
private SysLogService sysLogService;
@Override
public void log(BigInteger accountId, String actionName, String actionType, String actionUrl, Map<String, Object> detail) {
SysLog log = new SysLog();
log.setAccountId(accountId);
log.setActionName(actionName);
log.setActionType(actionType);
log.setActionUrl(actionUrl);
log.setActionBody(detail == null ? null : JSON.toJSONString(detail));
log.setCreated(new Date());
log.setStatus(0);
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
log.setActionIp(RequestUtil.getIpAddress(request));
log.setActionParams(request.getQueryString());
}
sysLogService.save(log);
}
}

View File

@@ -0,0 +1,174 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.entity.SysApiKeyResource;
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
import tech.easyflow.system.service.SysApiKeyResourceService;
import tech.easyflow.system.service.SysApiKeyService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 知识库分享权限服务实现。
*/
@Service
public class KnowledgeSharePermissionServiceImpl implements KnowledgeSharePermissionService {
public static final String RESOURCE_TYPE_KNOWLEDGE = "KNOWLEDGE";
private static final Map<String, List<String>> URI_SCOPE_MAPPING = new LinkedHashMap<>();
static {
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.VIEW.name(), List.of(
"/public-api/knowledge-share/detail",
"/public-api/knowledge-share/document/page",
"/public-api/knowledge-share/document/download",
"/public-api/knowledge-share/documentChunk/page",
"/public-api/knowledge-share/faq/page",
"/public-api/knowledge-share/faq/detail"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.SEARCH.name(), List.of(
"/public-api/knowledge-share/search"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_CREATE.name(), List.of(
"/public-api/knowledge-share/document/import/analyze",
"/public-api/knowledge-share/document/import/preview",
"/public-api/knowledge-share/document/import/commit",
"/public-api/knowledge-share/faq/save"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_UPDATE.name(), List.of(
"/public-api/knowledge-share/documentChunk/update",
"/public-api/knowledge-share/faq/update"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_DELETE.name(), List.of(
"/public-api/knowledge-share/document/remove",
"/public-api/knowledge-share/documentChunk/remove",
"/public-api/knowledge-share/faq/remove"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.IMPORT_EXPORT.name(), List.of(
"/public-api/knowledge-share/faq/importExcel",
"/public-api/knowledge-share/faq/exportExcel",
"/public-api/knowledge-share/faq/downloadImportTemplate"
));
}
@Resource
private SysApiKeyService sysApiKeyService;
@Resource
private SysApiKeyResourceService resourceService;
@Resource
private SysApiKeyResourceMappingService mappingService;
@Override
@Transactional(rollbackFor = Exception.class)
public void grantApiShare(BigInteger apiKeyId, BigInteger knowledgeId, Set<String> actionScopes) {
if (apiKeyId == null) {
throw new BusinessException("系统访问令牌不能为空");
}
if (knowledgeId == null) {
throw new BusinessException("知识库不能为空");
}
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
if (apiKey == null) {
throw new BusinessException("系统访问令牌不存在");
}
Set<String> normalizedScopes = KnowledgeShareActionScope.normalize(actionScopes);
if (normalizedScopes.isEmpty()) {
throw new BusinessException("动作范围不能为空");
}
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE, knowledgeId);
List<SysApiKeyResourceMapping> rows = new ArrayList<>();
for (String scope : normalizedScopes) {
List<String> uris = URI_SCOPE_MAPPING.get(scope);
if (uris == null || uris.isEmpty()) {
continue;
}
for (String uri : uris) {
SysApiKeyResource resource = ensureResource(uri);
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
row.setApiKeyId(apiKeyId);
row.setApiKeyResourceId(resource.getId());
row.setResourceType(RESOURCE_TYPE_KNOWLEDGE);
row.setResourceTargetId(knowledgeId);
row.setActionScope(scope);
rows.add(row);
}
}
if (!rows.isEmpty()) {
mappingService.saveBatch(rows);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceApiShareEnabled(BigInteger apiKeyId, boolean enabled) {
if (apiKeyId == null) {
throw new BusinessException("系统访问令牌不能为空");
}
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
if (apiKey == null) {
throw new BusinessException("系统访问令牌不存在");
}
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE);
if (!enabled) {
return;
}
List<SysApiKeyResourceMapping> rows = new ArrayList<>();
for (String scope : KnowledgeShareActionScope.defaultApiScopes()) {
List<String> uris = URI_SCOPE_MAPPING.get(scope);
if (uris == null || uris.isEmpty()) {
continue;
}
for (String uri : uris) {
SysApiKeyResource resource = ensureResource(uri);
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
row.setApiKeyId(apiKeyId);
row.setApiKeyResourceId(resource.getId());
row.setResourceType(RESOURCE_TYPE_KNOWLEDGE);
row.setActionScope(scope);
rows.add(row);
}
}
if (!rows.isEmpty()) {
mappingService.saveBatch(rows);
}
}
@Override
public void assertApiShare(BigInteger apiKeyId, String requestUri, BigInteger knowledgeId, String actionScope) {
if (apiKeyId == null || knowledgeId == null) {
throw new BusinessException("API 分享鉴权参数不完整");
}
sysApiKeyService.checkResourceScope(apiKeyId, requestUri, RESOURCE_TYPE_KNOWLEDGE, knowledgeId, actionScope);
}
private SysApiKeyResource ensureResource(String requestInterface) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(SysApiKeyResource::getRequestInterface, requestInterface);
SysApiKeyResource resource = resourceService.getOne(wrapper);
if (resource != null) {
return resource;
}
resource = new SysApiKeyResource();
resource.setRequestInterface(requestInterface);
resource.setTitle("知识库分享接口");
resourceService.save(resource);
return resource;
}
}

View File

@@ -0,0 +1,179 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.constants.KnowledgeShareErrorCode;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.KnowledgeShare;
import tech.easyflow.ai.enums.KnowledgeShareStatus;
import tech.easyflow.ai.enums.KnowledgeShareType;
import tech.easyflow.ai.mapper.KnowledgeShareMapper;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* 知识库分享服务实现。
*/
@Service
public class KnowledgeShareServiceImpl extends ServiceImpl<KnowledgeShareMapper, KnowledgeShare> implements KnowledgeShareService {
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
@Resource
private DocumentCollectionService documentCollectionService;
@Override
public KnowledgeShareUrlCreateResult createUrlShare(
BigInteger knowledgeId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl,
Set<String> permissionScopes
) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
invalidateExistingUrlShares(knowledgeId, operatorId);
String shareKey = UUID.randomUUID().toString().replace("-", "");
Date now = new Date();
Date expiresAt = new Date(now.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
KnowledgeShare share = new KnowledgeShare();
share.setKnowledgeId(knowledgeId);
share.setShareType(KnowledgeShareType.URL.name());
share.setShareKeyHash(hashShareKey(shareKey));
share.setStatus(KnowledgeShareStatus.ENABLED.name());
share.setPermissionScopes(permissionScopes);
share.setExpiresAt(expiresAt);
share.setTenantId(tenantId);
share.setDeptId(deptId);
share.setCreated(now);
share.setCreatedBy(operatorId);
share.setModified(now);
share.setModifiedBy(operatorId);
save(share);
KnowledgeShareUrlCreateResult result = new KnowledgeShareUrlCreateResult();
result.setId(share.getId());
result.setShareKey(shareKey);
result.setExpiresAt(expiresAt);
result.setShareUrl(buildShareUrl(baseUrl, shareKey));
return result;
}
private void invalidateExistingUrlShares(BigInteger knowledgeId, BigInteger operatorId) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(KnowledgeShare::getKnowledgeId, knowledgeId)
.eq(KnowledgeShare::getShareType, KnowledgeShareType.URL.name())
.eq(KnowledgeShare::getStatus, KnowledgeShareStatus.ENABLED.name());
List<KnowledgeShare> activeShares = list(wrapper);
if (activeShares == null || activeShares.isEmpty()) {
return;
}
Date now = new Date();
for (KnowledgeShare activeShare : activeShares) {
KnowledgeShare update = new KnowledgeShare();
update.setId(activeShare.getId());
update.setStatus(KnowledgeShareStatus.DISABLED.name());
update.setModified(now);
update.setModifiedBy(operatorId);
updateById(update);
}
}
@Override
public KnowledgeShareAuthContext validateUrlShare(String shareKey) {
if (shareKey == null || shareKey.isBlank()) {
throw invalidShare();
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(KnowledgeShare::getShareKeyHash, hashShareKey(shareKey));
KnowledgeShare share = getOne(wrapper);
if (share == null) {
throw invalidShare();
}
if (KnowledgeShareStatus.REVOKED.name().equals(share.getStatus())
|| KnowledgeShareStatus.DISABLED.name().equals(share.getStatus())) {
throw invalidShare();
}
if (share.getExpiresAt() == null || share.getExpiresAt().before(new Date())) {
throw expiredShare();
}
DocumentCollection knowledge = documentCollectionService.getById(share.getKnowledgeId());
if (knowledge == null) {
throw invalidShare();
}
KnowledgeShareAuthContext context = new KnowledgeShareAuthContext();
context.setShare(share);
context.setKnowledge(knowledge);
return context;
}
@Override
public KnowledgeShareAuthContext assertUrlShareAccess(
String shareKey,
BigInteger knowledgeId,
String actionScope
) {
KnowledgeShareAuthContext context = validateUrlShare(shareKey);
if (knowledgeId != null && context.getKnowledge() != null
&& context.getKnowledge().getId() != null
&& context.getKnowledge().getId().compareTo(knowledgeId) != 0) {
throw invalidShare();
}
if (actionScope != null && !actionScope.isBlank()
&& !context.getShare().getPermissionScopeSet().contains(actionScope.trim().toUpperCase())) {
throw forbiddenShare("当前分享不允许执行该操作");
}
return context;
}
private String buildShareUrl(String baseUrl, String shareKey) {
if (baseUrl == null || baseUrl.isBlank()) {
return null;
}
return baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ "shareKey=" + shareKey;
}
private String hashShareKey(String shareKey) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] value = digest.digest(shareKey.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(value);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private BusinessException expiredShare() {
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_EXPIRED + ":链接已过期");
}
private BusinessException invalidShare() {
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_INVALID + ":链接无效");
}
private BusinessException forbiddenShare(String message) {
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_FORBIDDEN + ":" + message);
}
}

View File

@@ -0,0 +1,32 @@
package tech.easyflow.ai.vo;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.KnowledgeShare;
import java.io.Serializable;
/**
* 知识库分享鉴权上下文。
*/
public class KnowledgeShareAuthContext implements Serializable {
private KnowledgeShare share;
private DocumentCollection knowledge;
public KnowledgeShare getShare() {
return share;
}
public void setShare(KnowledgeShare share) {
this.share = share;
}
public DocumentCollection getKnowledge() {
return knowledge;
}
public void setKnowledge(DocumentCollection knowledge) {
this.knowledge = knowledge;
}
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.ai.vo;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* URL 分享创建结果。
*/
public class KnowledgeShareUrlCreateResult implements Serializable {
private BigInteger id;
private String shareKey;
private String shareUrl;
private Date expiresAt;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public String getShareKey() {
return shareKey;
}
public void setShareKey(String shareKey) {
this.shareKey = shareKey;
}
public String getShareUrl() {
return shareUrl;
}
public void setShareUrl(String shareUrl) {
this.shareUrl = shareUrl;
}
public Date getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Date expiresAt) {
this.expiresAt = expiresAt;
}
}

View File

@@ -21,9 +21,20 @@ public class CurdInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
String normalizedRequestUri = requestURI;
if (StrUtil.isNotBlank(contextPath) && StrUtil.startWith(requestURI, contextPath)) {
normalizedRequestUri = requestURI.substring(contextPath.length());
}
log.info("进入 CurdInterceptor requestURI:{}", requestURI);
// 分享访问接口与公开 API 走各自的业务鉴权,不走后台通用 CRUD 权限码拦截。
if (StrUtil.startWith(normalizedRequestUri, "/api/v1/share/")
|| StrUtil.startWith(normalizedRequestUri, "/public-api/")) {
return true;
}
String groupName = "";
// 检查handler是否是HandlerMethod类型
if (handler instanceof HandlerMethod) {

View File

@@ -23,6 +23,9 @@ public class SysApiKey extends SysApiKeyBase {
@Column(ignore = true)
List<BigInteger> permissionIds;
@Column(ignore = true)
private Boolean knowledgeShareEnabled;
@RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping")
private List<SysApiKeyResourceMapping> resourcePermissions;
@@ -41,4 +44,12 @@ public class SysApiKey extends SysApiKeyBase {
public void setPermissionIds(List<BigInteger> permissionIds) {
this.permissionIds = permissionIds;
}
public Boolean getKnowledgeShareEnabled() {
return knowledgeShareEnabled;
}
public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) {
this.knowledgeShareEnabled = knowledgeShareEnabled;
}
}

View File

@@ -29,6 +29,24 @@ public class SysApiKeyResourceMappingBase implements Serializable {
@Column(comment = "请求接口资源访问id")
private BigInteger apiKeyResourceId;
/**
* 资源类型
*/
@Column(comment = "资源类型")
private String resourceType;
/**
* 资源目标ID
*/
@Column(comment = "资源目标ID")
private BigInteger resourceTargetId;
/**
* 动作范围
*/
@Column(comment = "动作范围")
private String actionScope;
public BigInteger getId() {
return id;
}
@@ -53,4 +71,28 @@ public class SysApiKeyResourceMappingBase implements Serializable {
this.apiKeyResourceId = apiKeyResourceId;
}
public String getResourceType() {
return resourceType;
}
public void setResourceType(String resourceType) {
this.resourceType = resourceType;
}
public BigInteger getResourceTargetId() {
return resourceTargetId;
}
public void setResourceTargetId(BigInteger resourceTargetId) {
this.resourceTargetId = resourceTargetId;
}
public String getActionScope() {
return actionScope;
}
public void setActionScope(String actionScope) {
this.actionScope = actionScope;
}
}

View File

@@ -4,6 +4,8 @@ import com.mybatisflex.core.service.IService;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
import java.math.BigInteger;
/**
* apikey-请求接口表 服务层。
*
@@ -13,4 +15,21 @@ import tech.easyflow.system.entity.SysApiKeyResourceMapping;
public interface SysApiKeyResourceMappingService extends IService<SysApiKeyResourceMapping> {
void authInterface(SysApiKey entity);
/**
* 移除指定 apiKey 下某个资源目标的 scope 授权。
*
* @param apiKeyId apiKey ID
* @param resourceType 资源类型
* @param resourceTargetId 资源目标 ID
*/
void removeScopedMappings(BigInteger apiKeyId, String resourceType, BigInteger resourceTargetId);
/**
* 按资源类型移除指定 apiKey 下的全部资源级授权。
*
* @param apiKeyId apiKey ID
* @param resourceType 资源类型
*/
void removeScopedMappings(BigInteger apiKeyId, String resourceType);
}

View File

@@ -3,6 +3,8 @@ package tech.easyflow.system.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.system.entity.SysApiKey;
import java.math.BigInteger;
/**
* 服务层。
*
@@ -15,4 +17,15 @@ public interface SysApiKeyService extends IService<SysApiKey> {
SysApiKey getSysApiKey(String apiKey);
/**
* 校验资源级作用域权限。
*
* @param apiKeyId apiKey 记录 ID
* @param requestURI 请求 URI
* @param resourceType 资源类型
* @param resourceTargetId 资源目标 ID
* @param actionScope 动作范围
*/
void checkResourceScope(BigInteger apiKeyId, String requestURI, String resourceType, BigInteger resourceTargetId, String actionScope);
}

View File

@@ -46,7 +46,10 @@ public class SysApiKeyResourceMappingServiceImpl extends ServiceImpl<SysApiKeyRe
return;
}
redisLockExecutor.executeWithLock(API_KEY_MAPPING_LOCK_PREFIX + apiKeyId, LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIMEOUT, () -> {
this.remove(QueryWrapper.create().eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId));
QueryWrapper removeWrapper = QueryWrapper.create()
.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)
.isNull(SysApiKeyResourceMapping::getResourceType);
this.remove(removeWrapper);
if (entity.getPermissionIds() == null || entity.getPermissionIds().isEmpty()) {
return;
}
@@ -66,4 +69,29 @@ public class SysApiKeyResourceMappingServiceImpl extends ServiceImpl<SysApiKeyRe
}
});
}
@Override
@Transactional(rollbackFor = Exception.class)
public void removeScopedMappings(BigInteger apiKeyId, String resourceType, BigInteger resourceTargetId) {
if (apiKeyId == null || resourceTargetId == null || resourceType == null || resourceType.isBlank()) {
return;
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)
.eq(SysApiKeyResourceMapping::getResourceType, resourceType)
.eq(SysApiKeyResourceMapping::getResourceTargetId, resourceTargetId);
remove(wrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void removeScopedMappings(BigInteger apiKeyId, String resourceType) {
if (apiKeyId == null || resourceType == null || resourceType.isBlank()) {
return;
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)
.eq(SysApiKeyResourceMapping::getResourceType, resourceType);
remove(wrapper);
}
}

View File

@@ -79,4 +79,36 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
return one;
}
@Override
public void checkResourceScope(BigInteger apiKeyId, String requestURI, String resourceType, BigInteger resourceTargetId, String actionScope) {
List<String> candidateRequestUris = getCandidateRequestUris(requestURI);
QueryWrapper resourceWrapper = QueryWrapper.create();
resourceWrapper.in(SysApiKeyResource::getRequestInterface, candidateRequestUris);
List<SysApiKeyResource> resources = resourceService.list(resourceWrapper);
if (resources == null || resources.isEmpty()) {
throw new BusinessException("该接口不存在");
}
List<BigInteger> resourceIds = resources.stream()
.map(SysApiKeyResource::getId)
.toList();
QueryWrapper exactScopeWrapper = QueryWrapper.create()
.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)
.in(SysApiKeyResourceMapping::getApiKeyResourceId, resourceIds)
.eq(SysApiKeyResourceMapping::getResourceType, resourceType)
.eq(SysApiKeyResourceMapping::getResourceTargetId, resourceTargetId)
.eq(SysApiKeyResourceMapping::getActionScope, actionScope);
if (mappingService.count(exactScopeWrapper) > 0) {
return;
}
QueryWrapper globalScopeWrapper = QueryWrapper.create();
globalScopeWrapper.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId);
globalScopeWrapper.in(SysApiKeyResourceMapping::getApiKeyResourceId, resourceIds);
globalScopeWrapper.eq(SysApiKeyResourceMapping::getResourceType, resourceType);
globalScopeWrapper.isNull(SysApiKeyResourceMapping::getResourceTargetId);
globalScopeWrapper.eq(SysApiKeyResourceMapping::getActionScope, actionScope);
if (mappingService.count(globalScopeWrapper) == 0) {
throw new BusinessException("该apiKey无权限访问当前资源");
}
}
}