diff --git a/codestyle-admin/codestyle-common/src/main/java/top/codestyle/admin/common/context/ApiTenantContextHolder.java b/codestyle-admin/codestyle-common/src/main/java/top/codestyle/admin/common/context/ApiTenantContextHolder.java new file mode 100644 index 0000000..0a85f5f --- /dev/null +++ b/codestyle-admin/codestyle-common/src/main/java/top/codestyle/admin/common/context/ApiTenantContextHolder.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022-present CodeStyle Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.codestyle.admin.common.context; + +/** + * Open API 租户上下文 + * + * @author CodeStyle Team + * @since 1.0.0 + */ +public class ApiTenantContextHolder { + + private static final ThreadLocal TENANT_ID_HOLDER = new ThreadLocal<>(); + + private ApiTenantContextHolder() { + } + + public static void setTenantId(Long tenantId) { + TENANT_ID_HOLDER.set(tenantId); + } + + public static Long getTenantId() { + return TENANT_ID_HOLDER.get(); + } + + public static void clear() { + TENANT_ID_HOLDER.remove(); + } +} diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/entity/AppDO.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/entity/AppDO.java index 0fe5a01..67f7cb2 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/entity/AppDO.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/entity/AppDO.java @@ -18,7 +18,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; -import top.codestyle.admin.common.base.model.entity.BaseDO; +import top.codestyle.admin.common.base.model.entity.TenantBaseDO; import top.codestyle.admin.common.enums.DisEnableStatusEnum; import top.continew.starter.encrypt.field.annotation.FieldEncrypt; @@ -34,7 +34,7 @@ */ @Data @TableName("sys_app") -public class AppDO extends BaseDO { +public class AppDO extends TenantBaseDO { @Serial private static final long serialVersionUID = 1L; @@ -82,4 +82,4 @@ public boolean isExpired() { } return LocalDateTime.now().isAfter(expireTime); } -} \ No newline at end of file +} diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/req/AppReq.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/req/AppReq.java index 29456e7..e937435 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/req/AppReq.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/model/req/AppReq.java @@ -80,4 +80,10 @@ public class AppReq implements Serializable { */ @Schema(hidden = true) private String secretKey; -} \ No newline at end of file + + /** + * 租户 ID + */ + @Schema(hidden = true) + private Long tenantId; +} diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/service/impl/AppServiceImpl.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/service/impl/AppServiceImpl.java index de8f7fd..8ef6451 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/service/impl/AppServiceImpl.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/service/impl/AppServiceImpl.java @@ -19,8 +19,10 @@ import cn.hutool.core.codec.Base64; import cn.hutool.core.util.IdUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import top.codestyle.admin.common.base.service.BaseServiceImpl; +import top.codestyle.admin.common.context.UserContextHolder; import top.codestyle.admin.open.mapper.AppMapper; import top.codestyle.admin.open.model.entity.AppDO; import top.codestyle.admin.open.model.query.AppQuery; @@ -39,6 +41,7 @@ * @since 2024/10/17 16:03 */ @Service +@Slf4j public class AppServiceImpl extends BaseServiceImpl implements AppService { @Override @@ -48,6 +51,13 @@ public void beforeCreate(AppReq req) { .replace(StringConstants.PLUS, StringConstants.EMPTY) .substring(0, 30)); req.setSecretKey(this.generateSecret()); + Long tenantId = UserContextHolder.getContext().getTenantId(); + log.info("创建 App 前获取当前用户租户: tenantId={}", tenantId); + if (tenantId == null) { + tenantId = 0L; + } + req.setTenantId(tenantId); + log.info("创建 App 写入 tenantId: tenantId={}, accessKey={}", tenantId, req.getAccessKey()); } @Override @@ -82,4 +92,4 @@ private String generateSecret() { .replace(StringConstants.SLASH, StringConstants.EMPTY) .replace(StringConstants.PLUS, StringConstants.EMPTY); } -} \ No newline at end of file +} diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/sign/OpenApiSignTemplate.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/sign/OpenApiSignTemplate.java index b43afe7..1474476 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/sign/OpenApiSignTemplate.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-open/src/main/java/top/codestyle/admin/open/sign/OpenApiSignTemplate.java @@ -19,7 +19,9 @@ import cn.dev33.satoken.secure.SaSecureUtil; import cn.dev33.satoken.sign.template.SaSignTemplate; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import top.codestyle.admin.common.context.ApiTenantContextHolder; import top.codestyle.admin.common.enums.DisEnableStatusEnum; import top.codestyle.admin.open.model.entity.AppDO; import top.codestyle.admin.open.service.AppService; @@ -35,6 +37,7 @@ * @since 2024/10/17 16:03 */ @Component +@Slf4j @RequiredArgsConstructor public class OpenApiSignTemplate extends SaSignTemplate { @@ -58,12 +61,22 @@ public void checkParamMap(Map paramMap) { ValidationUtils.throwIfNull(app, "accessKey无效"); ValidationUtils.throwIfEqual(DisEnableStatusEnum.DISABLE, app.getStatus(), "应用已被禁用, 请联系管理员"); ValidationUtils.throwIf(app.isExpired(), "应用已过期, 请联系管理员"); + log.info("OpenAPI 验签开始: accessKey={}, dbTenantId={}", accessKeyValue, app.getTenantId()); + if (app.getTenantId() == null) { + app.setTenantId(0L); + log.info("OpenAPI 验签租户兜底后: accessKey={}, effectiveTenantId={}", accessKeyValue, app.getTenantId()); + } // 依次校验三个参数 super.checkTimestamp(Long.parseLong(timestampValue)); super.checkNonce(nonceValue); paramMap.put(key, app.getSecretKey()); super.checkSign(paramMap, signValue); + + // 记录 API 请求租户上下文(基于 AK/SK 绑定的应用) + ApiTenantContextHolder.setTenantId(app.getTenantId()); + log.info("OpenAPI 验签完成并写入 ApiTenantContextHolder: accessKey={}, tenantId={}", accessKeyValue, app + .getTenantId()); } @Override diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/controller/SearchController.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/controller/SearchController.java index 89f5255..8df70c1 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/controller/SearchController.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/controller/SearchController.java @@ -20,10 +20,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import top.codestyle.admin.search.model.SearchRequest; import top.codestyle.admin.search.model.SearchResult; import top.codestyle.admin.search.service.SearchService; +import top.codestyle.admin.search.util.SearchTenantUtils; import java.util.List; @@ -33,6 +35,7 @@ * @author CodeStyle Team * @since 1.0.0 */ +@Slf4j @Tag(name = "检索 API") @RestController @RequiredArgsConstructor @@ -80,6 +83,9 @@ public List openApiSearch(@RequestParam String query, SearchRequest request = new SearchRequest(); request.setQuery(query); request.setTopK(topK); + Long tenantId = SearchTenantUtils.resolveCurrentTenantId(); + request.setTenantId(tenantId); + log.info("OpenAPI 搜索请求写入 tenantId: query={}, topK={}, tenantId={}", query, topK, tenantId); return searchService.search(request); } } diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/model/SearchRequest.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/model/SearchRequest.java index 48af8d5..0772b34 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/model/SearchRequest.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/model/SearchRequest.java @@ -56,4 +56,7 @@ public class SearchRequest { @Schema(description = "关键词检索权重(混合检索时,0-1之间)", example = "0.4") private Double keywordWeight = 0.4; + + @Schema(hidden = true) + private Long tenantId; } diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/ElasticsearchSearchServiceImpl.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/ElasticsearchSearchServiceImpl.java index cad7a2d..ada6710 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/ElasticsearchSearchServiceImpl.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/ElasticsearchSearchServiceImpl.java @@ -17,6 +17,7 @@ package top.codestyle.admin.search.service.impl; import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.query_dsl.Operator; import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; @@ -29,8 +30,8 @@ import top.codestyle.admin.search.model.SearchResult; import top.codestyle.admin.search.model.SearchSourceType; import top.codestyle.admin.search.service.ElasticsearchSearchService; +import top.codestyle.admin.search.util.SearchTenantUtils; import top.continew.starter.core.exception.BusinessException; - import java.io.IOException; import java.util.List; import java.util.Map; @@ -60,10 +61,13 @@ public class ElasticsearchSearchServiceImpl implements ElasticsearchSearchServic @Override public List search(SearchRequest request) { try { - log.debug("开始 ES 检索,查询: {}, topK: {}", request.getQuery(), request.getTopK()); + log.info("开始 ES 检索,查询: {}, topK: {}, requestTenantId={}", request.getQuery(), request.getTopK(), request + .getTenantId()); // 1. 构建查询 co.elastic.clients.elasticsearch.core.SearchRequest esRequest = buildSearchRequest(request); + log.info("ES 检索请求已构建: index={}, tenantFilter={}, query={}", properties.getElasticsearch() + .getIndex(), request.getTenantId() != null ? request.getTenantId() : 0L, request.getQuery()); // 2. 执行查询 SearchResponse response = esClient.search(esRequest, Map.class); @@ -71,7 +75,7 @@ public List search(SearchRequest request) { // 3. 转换结果 List results = convertResults(response); - log.debug("ES 检索完成,返回 {} 条结果", results.size()); + log.info("ES 检索完成,返回 {} 条结果", results.size()); return results; } catch (IOException e) { @@ -86,19 +90,22 @@ public List search(SearchRequest request) { /** * 构建 ES 查询请求 *

- * 多字段加权检索: - * - title^3: 标题权重最高 - * - content^2: 内容权重次之 - * - tags^1: 标签权重最低 + * 模板级元数据检索: + * - name/artifactId: 标题和模板标识 + * - description/summary: 模板描述摘要 + * - tags.text: 中文标签检索 */ private co.elastic.clients.elasticsearch.core.SearchRequest buildSearchRequest(SearchRequest request) { + Long tenantId = SearchTenantUtils.resolveSearchTenantId(request); return co.elastic.clients.elasticsearch.core.SearchRequest.of(s -> s.index(properties.getElasticsearch() .getIndex()) - .query(q -> q.multiMatch(m -> m.query(request.getQuery()) - .fields("title^3", "content^2", "tags^1") // 字段加权 - .type(TextQueryType.BestFields))) + .query(q -> q.bool(b -> b.must(m -> m.multiMatch(mm -> mm.query(request.getQuery()) + .fields("name^4", "artifactId.text^3", "description^3", "summary^2", "tags.text^3") + .type(TextQueryType.BestFields) + .operator(Operator.Or))).filter(f -> f.term(t -> t.field("tenantId").value(String.valueOf(tenantId)))))) .size(request.getTopK()) - .highlight(h -> h.fields("content", f -> f.numberOfFragments(1).fragmentSize(200)))); + .highlight(h -> h.fields("description", f -> f.numberOfFragments(1).fragmentSize(160)) + .fields("summary", f -> f.numberOfFragments(1).fragmentSize(160)))); } /** @@ -118,34 +125,45 @@ private SearchResult convertHit(Hit hit) { .id(hit.id()) .sourceType(SearchSourceType.ELASTICSEARCH) .title(getStringValue(source, "title")) - .content(getStringValue(source, "content")) - .snippet(getStringValue(source, "snippet")) + .content(getStringValue(source, "description")) + .snippet(extractSnippet(hit, source)) .score(hit.score()) .highlight(extractHighlight(hit)) - .metadata(source) // 完整的 metadata,包含所有字段(包括非索引字段) - // MCP 必要字段(从 ES 索引中提取) + .metadata(source) .groupId(getStringValue(source, "groupId")) .artifactId(getStringValue(source, "artifactId")) .version(getStringValue(source, "version")) .fileType(getStringValue(source, "fileType")) .build(); - // 注意:filePath, filename, sha256 等非索引字段 - // 已经包含在 metadata 中,通过 SearchResult 的 getter 方法访问 } /** * 提取高亮片段 */ private String extractHighlight(Hit hit) { - if (hit.highlight() != null && hit.highlight().containsKey("content")) { - List fragments = hit.highlight().get("content"); - if (!fragments.isEmpty()) { - return fragments.get(0); + if (hit.highlight() != null) { + if (hit.highlight().containsKey("description") && !hit.highlight().get("description").isEmpty()) { + return hit.highlight().get("description").get(0); + } + if (hit.highlight().containsKey("summary") && !hit.highlight().get("summary").isEmpty()) { + return hit.highlight().get("summary").get(0); } } return null; } + private String extractSnippet(Hit hit, Map source) { + String highlight = extractHighlight(hit); + if (highlight != null) { + return highlight; + } + String summary = getStringValue(source, "summary"); + if (summary != null) { + return summary; + } + return getStringValue(source, "description"); + } + /** * 安全获取字符串值 */ diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/TemplateFileServiceImpl.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/TemplateFileServiceImpl.java index e8ba7cc..6b9cf92 100644 --- a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/TemplateFileServiceImpl.java +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/service/impl/TemplateFileServiceImpl.java @@ -19,8 +19,6 @@ import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import com.github.benmanes.caffeine.cache.Cache; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.archivers.ArchiveEntry; @@ -36,18 +34,22 @@ import top.codestyle.admin.common.context.UserContext; import top.codestyle.admin.common.context.UserContextHolder; import top.codestyle.admin.search.config.SearchProperties; -import top.codestyle.admin.search.helper.CacheHelper; import top.codestyle.admin.search.model.MetaJson; import top.codestyle.admin.search.model.resp.TemplateUploadResp; import top.codestyle.admin.search.service.TemplateFileService; +import top.codestyle.admin.search.util.SearchTenantUtils; import top.codestyle.admin.system.enums.FileTypeEnum; import top.codestyle.admin.system.mapper.FileMapper; import top.codestyle.admin.system.model.entity.FileDO; import top.codestyle.admin.system.model.entity.StorageDO; import top.codestyle.admin.system.service.FileService; import top.codestyle.admin.system.service.StorageService; +import top.continew.starter.cache.redisson.util.RedisLockUtils; +import top.continew.starter.core.exception.BusinessException; import top.continew.starter.core.util.validation.CheckUtils; +import co.elastic.clients.elasticsearch.ElasticsearchClient; + import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -83,7 +85,6 @@ public class TemplateFileServiceImpl implements TemplateFileService { private final FileMapper fileMapper; private final ElasticsearchClient esClient; private final SearchProperties searchProperties; - private final Cache> localCache; // ==================== 公开接口实现 ==================== @@ -96,9 +97,10 @@ public TemplateUploadResp uploadTemplate(MultipartFile file, Boolean overwrite) throws IOException { Path tempDir = null; File tempArchiveFile = null; + Long tenantId = SearchTenantUtils.resolveCurrentTenantId(); // 确保有用户上下文(开放 API 可能无登录状态) - UserContext originalContext = ensureUserContext(); + UserContext originalContext = ensureUserContext(tenantId); try { // 1. 创建临时目录 @@ -129,33 +131,41 @@ public TemplateUploadResp uploadTemplate(MultipartFile file, CheckUtils.throwIfBlank(version, "version 不能为空,请在 meta.json 中指定"); validateTemplateFiles(templateRoot, metaJson); - // 6. 检查是否已存在同版本文件 - String templatePrefix = buildTemplatePrefix(groupId, artifactId, version); - boolean exists = fileMapper.lambdaQuery().likeRight(FileDO::getParentPath, templatePrefix).exists(); - if (exists) { - if (Boolean.TRUE.equals(overwrite)) { - deleteOldVersionFiles(groupId, artifactId, version); - } else { - CheckUtils.throwIf(true, "模板 %s:%s:%s 已存在,如需覆盖请设置 overwrite=true" - .formatted(groupId, artifactId, version)); + // 6. 上传幂等控制 + String lockKey = String.format("lock:template:upload:%s:%s:%s", groupId, artifactId, version); + log.info("模板上传开始: groupId={}, artifactId={}, version={}, lockKey={}", groupId, artifactId, version, lockKey); + try (RedisLockUtils lock = RedisLockUtils.tryLock(lockKey, 30, 60)) { + if (!lock.isLocked()) { + throw new BusinessException("模板上传处理中,请勿重复提交"); + } + log.info("模板上传获取分布式锁成功: lockKey={}", lockKey); + + // 6.1 检查是否已存在同版本文件 + String templatePrefix = buildTemplatePrefix(groupId, artifactId, version); + boolean exists = fileMapper.lambdaQuery().likeRight(FileDO::getParentPath, templatePrefix).exists(); + if (exists) { + if (Boolean.TRUE.equals(overwrite)) { + deleteOldVersionFiles(groupId, artifactId, version); + deleteFromElasticsearch(tenantId, groupId, artifactId, version); + } else { + CheckUtils.throwIf(true, "模板 %s:%s:%s 已存在,如需覆盖请设置 overwrite=true" + .formatted(groupId, artifactId, version)); + } } - } - // 7. 逐个文件上传到 FileService - uploadExtractedFiles(templateRoot, templateRoot, templatePrefix); + // 7. 逐个文件上传到 FileService + uploadExtractedFiles(templateRoot, templateRoot, templatePrefix); - // 8. 索引到 Elasticsearch - indexToElasticsearch(groupId, artifactId, version, templatePrefix, metaJson); + // 8. 若 meta.json 没有 description,尝试从 README 中提取 + if (StrUtil.isBlank(metaJson.getDescription())) { + metaJson.setDescription(readReadmeContent(templateRoot)); + } - // 9. 若 meta.json 没有 description,尝试从 README 中提取 - if (StrUtil.isBlank(metaJson.getDescription())) { - metaJson.setDescription(readReadmeContent(templateRoot)); + // 9. 写入 ES 索引(租户隔离) + indexToElasticsearch(tenantId, groupId, artifactId, version, templatePrefix, metaJson); } - // 10. 清除搜索缓存(因为新增了索引) - CacheHelper.evictAllCache(); - - // 11. 构建响应 + // 10. 构建响应 String downloadUrl = String .format("/open-api/template/download?groupId=%s&artifactId=%s&version=%s", groupId, artifactId, version); return buildUploadResponse(groupId, artifactId, version, metaJson, downloadUrl); @@ -263,31 +273,16 @@ public File createTemplateZip(String groupId, String artifactId, String version) @Override public void deleteTemplateFiles(String groupId, String artifactId, String version) { - // 1. 删除数据库和存储文件 - deleteOldVersionFiles(groupId, artifactId, version); - - // 2. 删除 ES 索引 - deleteFromElasticsearch(groupId, artifactId, version); - - // 3. 清除搜索缓存(L1 + L2) - localCache.invalidateAll(); // 清除 L1 Caffeine 本地缓存 - CacheHelper.evictAllCache(); // 清除 L2 Redis 缓存 - } - - /** - * 从 Elasticsearch 删除模板索引 - */ - private void deleteFromElasticsearch(String groupId, String artifactId, String version) { - try { - // 直接删除(按模板维度,1个模板1条文档) - String docId = groupId + ":" + artifactId + ":" + version; - - esClient.delete(d -> d.index(searchProperties.getElasticsearch().getIndex()).id(docId)); - - log.info("Deleted ES index for {}/{}/{}", groupId, artifactId, version); - } catch (Exception e) { - log.error("Failed to delete ES index for {}/{}/{}, error: {}", groupId, artifactId, version, e - .getMessage(), e); + Long tenantId = SearchTenantUtils.resolveCurrentTenantId(); + String lockKey = String.format("lock:template:delete:%s:%s:%s", groupId, artifactId, version); + log.info("模板删除开始: groupId={}, artifactId={}, version={}, lockKey={}", groupId, artifactId, version, lockKey); + try (RedisLockUtils lock = RedisLockUtils.tryLock(lockKey, 30, 60)) { + if (!lock.isLocked()) { + throw new BusinessException("模板删除处理中,请勿重复提交"); + } + log.info("模板删除获取分布式锁成功: lockKey={}", lockKey); + deleteOldVersionFiles(groupId, artifactId, version); + deleteFromElasticsearch(tenantId, groupId, artifactId, version); } } @@ -639,13 +634,14 @@ private TemplateUploadResp buildUploadResponse(String groupId, * * @return 原始上下文(若为 null 说明是新创建的系统上下文,需要在 finally 中清除) */ - private UserContext ensureUserContext() { + private UserContext ensureUserContext(Long tenantId) { try { return UserContextHolder.getContext(); } catch (Exception e) { UserContext systemContext = new UserContext(); systemContext.setId(1L); systemContext.setUsername("system"); + systemContext.setTenantId(tenantId); UserContextHolder.setContext(systemContext, false); return null; } @@ -658,95 +654,98 @@ private void restoreUserContext(UserContext originalContext) { } } - /** - * 索引模板到 Elasticsearch(按模板维度,每个模板1条文档) - */ - private void indexToElasticsearch(String groupId, + private void indexToElasticsearch(Long tenantId, + String groupId, String artifactId, String version, String templatePrefix, MetaJson metaJson) { try { - // 查询刚上传的所有文件 List files = fileMapper.lambdaQuery() .likeRight(FileDO::getParentPath, templatePrefix) .ne(FileDO::getType, FileTypeEnum.DIR) .list(); - if (files.isEmpty()) { - log.warn("No files found to index for {}/{}/{}", groupId, artifactId, version); return; } + String docId = buildEsDocId(tenantId, groupId, artifactId, version); + log.info("ES 索引模板: index={}, docId={}, tenantId={}, groupId={}, artifactId={}, version={}", searchProperties + .getElasticsearch() + .getIndex(), docId, tenantId, groupId, artifactId, version); - // 合并所有文件内容为一个大文档(按模板维度) - StringBuilder contentBuilder = new StringBuilder(); - StorageDO storage = storageService.getDefaultStorage(); - - for (FileDO file : files) { - try { - if (file.getSize() != null && file.getSize() < 512 * 1024) { - FileInfo fileInfo = file.toFileInfo(storage); - byte[] bytes = fileStorageService.download(fileInfo).bytes(); - contentBuilder.append(file.getOriginalName()) - .append("\n") - .append(new String(bytes, StandardCharsets.UTF_8)) - .append("\n\n"); - } - } catch (Exception e) { - log.warn("Failed to read file content: {}/{}, error: {}", file.getParentPath(), file - .getOriginalName(), e.getMessage()); - } - } - - // 构建 ES 文档(按模板维度:1个模板 = 1条文档) Map doc = new HashMap<>(); - doc.put("title", artifactId); // 标题用 artifactId - doc.put("content", contentBuilder.toString()); + doc.put("tenantId", tenantId); + doc.put("id", docId); + doc.put("title", artifactId); + doc.put("name", metaJson.getName()); + doc.put("summary", buildSummary(metaJson, files.size())); doc.put("groupId", groupId); doc.put("artifactId", artifactId); doc.put("version", version); doc.put("tags", metaJson.getTags()); doc.put("description", metaJson.getDescription()); + doc.put("fileCount", files.size()); + doc.put("keywords", buildKeywords(metaJson, groupId, artifactId)); + doc.put("createTime", files.get(0).getCreateTime() != null + ? files.get(0).getCreateTime().toString() + : null); - // 使用 groupId:artifactId:version 作为文档 ID(1个模板1条) - String docId = groupId + ":" + artifactId + ":" + version; - - // 直接写入1条文档 esClient.index(i -> i.index(searchProperties.getElasticsearch().getIndex()).id(docId).document(doc)); + log.info("ES 索引写入成功: docId={}", docId); + } catch (Exception e) { + log.error("ES 索引写入失败: groupId={}, artifactId={}, version={}", groupId, artifactId, version, e); + throw new BusinessException("模板索引失败"); + } + } - log.info("Successfully indexed template to ES: {}/{}/{}", groupId, artifactId, version); - + private void deleteFromElasticsearch(Long tenantId, String groupId, String artifactId, String version) { + try { + String docId = buildEsDocId(tenantId, groupId, artifactId, version); + log.info("ES 删除模板: index={}, docId={}, tenantId={}, groupId={}, artifactId={}, version={}", searchProperties + .getElasticsearch() + .getIndex(), docId, tenantId, groupId, artifactId, version); + esClient.delete(d -> d.index(searchProperties.getElasticsearch().getIndex()).id(docId)); + log.info("ES 删除成功: docId={}", docId); } catch (Exception e) { - log.error("Failed to index to Elasticsearch: {}/{}/{}, error: {}", groupId, artifactId, version, e - .getMessage(), e); + log.warn("删除 ES 索引失败: {}/{}/{}", groupId, artifactId, version, e); + } + } + + private String buildEsDocId(Long tenantId, String groupId, String artifactId, String version) { + return tenantId + ":" + groupId + ":" + artifactId + ":" + version; + } + + private String buildSummary(MetaJson metaJson, int fileCount) { + String description = StrUtil.blankToDefault(metaJson.getDescription(), StrUtil.blankToDefault(metaJson + .getName(), "")); + String summary = description; + if (summary.length() > 300) { + summary = summary.substring(0, 300); } + return summary + " | fileCount=" + fileCount; } - private String getFileType(String filename) { - if (filename == null) { - return "unknown"; + private List buildKeywords(MetaJson metaJson, String groupId, String artifactId) { + Set keywords = new LinkedHashSet<>(); + keywords.add(groupId); + keywords.add(artifactId); + if (StrUtil.isNotBlank(metaJson.getName())) { + keywords.add(metaJson.getName()); + } + if (metaJson.getTags() != null) { + keywords.addAll(metaJson.getTags()); + } + if (metaJson.getFiles() != null) { + metaJson.getFiles().stream().limit(20).forEach(file -> { + if (StrUtil.isNotBlank(file.getFilename())) { + keywords.add(file.getFilename()); + } + if (StrUtil.isNotBlank(file.getFilePath())) { + keywords.add(file.getFilePath()); + } + }); } - String lowerName = filename.toLowerCase(); - if (lowerName.endsWith(".java")) - return "java"; - if (lowerName.endsWith(".ftl")) - return "ftl"; - if (lowerName.endsWith(".vue")) - return "vue"; - if (lowerName.endsWith(".js")) - return "js"; - if (lowerName.endsWith(".ts")) - return "ts"; - if (lowerName.endsWith(".sql")) - return "sql"; - if (lowerName.endsWith(".xml")) - return "xml"; - if (lowerName.endsWith(".json")) - return "json"; - if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) - return "yaml"; - if (lowerName.endsWith(".md")) - return "md"; - return "other"; + return new ArrayList<>(keywords); } + } diff --git a/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/util/SearchTenantUtils.java b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/util/SearchTenantUtils.java new file mode 100644 index 0000000..f981386 --- /dev/null +++ b/codestyle-admin/codestyle-plugin/codestyle-plugin-search/src/main/java/top/codestyle/admin/search/util/SearchTenantUtils.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022-present CodeStyle Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.codestyle.admin.search.util; + +import lombok.extern.slf4j.Slf4j; +import top.codestyle.admin.common.context.ApiTenantContextHolder; +import top.codestyle.admin.search.model.SearchRequest; +import top.continew.starter.extension.tenant.context.TenantContextHolder; + +/** + * 检索模块租户工具 + * + * @author CodeStyle Team + * @since 1.0.0 + */ +@Slf4j +public final class SearchTenantUtils { + + private SearchTenantUtils() { + } + + public static Long resolveSearchTenantId(SearchRequest request) { + Long tenantId = request.getTenantId(); + Long effectiveTenantId = tenantId != null ? tenantId : 0L; + log.info("ES 租户解析 request.tenantId={}, effectiveTenantId={}", tenantId, effectiveTenantId); + return effectiveTenantId; + } + + public static Long resolveCurrentTenantId() { + Long apiTenantId = ApiTenantContextHolder.getTenantId(); + if (apiTenantId != null) { + log.info("模板租户解析从 ApiTenantContextHolder 获取 tenantId={}", apiTenantId); + return apiTenantId; + } + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + log.info("模板租户解析从 TenantContextHolder 获取 tenantId={}", tenantId); + return tenantId; + } + log.info("模板租户解析使用默认 tenantId=0"); + return 0L; + } +} diff --git a/codestyle-admin/codestyle-server/src/main/java/top/codestyle/admin/config/satoken/SaExtensionInterceptor.java b/codestyle-admin/codestyle-server/src/main/java/top/codestyle/admin/config/satoken/SaExtensionInterceptor.java index 178175e..0ef0f13 100644 --- a/codestyle-admin/codestyle-server/src/main/java/top/codestyle/admin/config/satoken/SaExtensionInterceptor.java +++ b/codestyle-admin/codestyle-server/src/main/java/top/codestyle/admin/config/satoken/SaExtensionInterceptor.java @@ -24,6 +24,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; +import top.codestyle.admin.common.context.ApiTenantContextHolder; import top.codestyle.admin.common.context.UserContext; import top.codestyle.admin.common.context.UserContextHolder; import top.continew.starter.core.util.ServletUtils; @@ -80,6 +81,7 @@ public void afterCompletion(HttpServletRequest request, try { super.afterCompletion(request, response, handler, e); } finally { + ApiTenantContextHolder.clear(); UserContextHolder.clearContext(); } } diff --git a/codestyle-admin/codestyle-system/src/main/java/top/codestyle/admin/system/model/entity/FileDO.java b/codestyle-admin/codestyle-system/src/main/java/top/codestyle/admin/system/model/entity/FileDO.java index 5741077..80a47da 100644 --- a/codestyle-admin/codestyle-system/src/main/java/top/codestyle/admin/system/model/entity/FileDO.java +++ b/codestyle-admin/codestyle-system/src/main/java/top/codestyle/admin/system/model/entity/FileDO.java @@ -24,7 +24,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.dromara.x.file.storage.core.FileInfo; -import top.codestyle.admin.common.base.model.entity.BaseDO; +import top.codestyle.admin.common.base.model.entity.TenantBaseDO; import top.codestyle.admin.system.enums.FileTypeEnum; import top.continew.starter.core.constant.StringConstants; @@ -40,7 +40,7 @@ @Data @NoArgsConstructor @TableName("sys_file") -public class FileDO extends BaseDO { +public class FileDO extends TenantBaseDO { @Serial private static final long serialVersionUID = 1L;