完善一键生成项目的稳定性与验证
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
package com.ruoyi.generator.factory.ai.stage.oneclick;
|
package com.ruoyi.generator.factory.ai.stage.oneclick;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import com.ruoyi.common.exception.ServiceException;
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
@@ -78,11 +82,28 @@ public class GenerateStageHandler implements OneClickGenerationStageHandler
|
|||||||
}
|
}
|
||||||
result.setGenerationRunId(generated.getGenerationRunId());
|
result.setGenerationRunId(generated.getGenerationRunId());
|
||||||
stagePipeline.linkGenerationRun(context.getTask(), stageCode(), generated.getGenerationRunId());
|
stagePipeline.linkGenerationRun(context.getTask(), stageCode(), generated.getGenerationRunId());
|
||||||
for (String type : previewService.getSupportedTemplateTypes(userId, projectId))
|
List<String> templateTypes = previewService.getSupportedTemplateTypes(userId, projectId);
|
||||||
|
if (templateTypes == null || templateTypes.isEmpty())
|
||||||
{
|
{
|
||||||
previewService.getStructure(userId, projectId, type);
|
throw new ServiceException("代码模板没有可生成的项目类型");
|
||||||
|
}
|
||||||
|
Set<String> renderedTypes = new HashSet<String>();
|
||||||
|
for (String type : templateTypes)
|
||||||
|
{
|
||||||
|
if (type == null || type.trim().length() == 0 || !renderedTypes.add(type))
|
||||||
|
{
|
||||||
|
throw new ServiceException("代码模板包含无效或重复的项目类型");
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> structure = previewService.getStructure(userId, projectId, type);
|
||||||
|
if (structure == null || structure.isEmpty())
|
||||||
|
{
|
||||||
|
throw new ServiceException("生成结果缺少项目结构: " + type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previewService.markPreviewReady(userId, projectId) != 1)
|
||||||
|
{
|
||||||
|
throw new ServiceException("无法标记项目源码为可预览状态");
|
||||||
}
|
}
|
||||||
previewService.markPreviewReady(userId, projectId);
|
|
||||||
result.setDownloadReady(true);
|
result.setDownloadReady(true);
|
||||||
checkpointService.save(context.getTask(), stageCode(), result,
|
checkpointService.save(context.getTask(), stageCode(), result,
|
||||||
result.getSpecVersionId(), result.getGenerationRunId());
|
result.getSpecVersionId(), result.getGenerationRunId());
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ public class RunPreviewStageHandler implements OneClickGenerationStageHandler
|
|||||||
context.getResult().getSpecVersionId(), context.getResult().getGenerationRunId());
|
context.getResult().getSpecVersionId(), context.getResult().getGenerationRunId());
|
||||||
if (stageCode().equals(context.getResult().getFailedStage()))
|
if (stageCode().equals(context.getResult().getFailedStage()))
|
||||||
{
|
{
|
||||||
stagePipeline.fail(context.getTask(), stageCode(),
|
ServiceException failure = new ServiceException(context.getResult().getErrorMessage());
|
||||||
new ServiceException(context.getResult().getErrorMessage()));
|
stagePipeline.fail(context.getTask(), stageCode(), failure);
|
||||||
|
throw failure;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public interface AiGenerationTaskMapper
|
|||||||
public List<AiGenerationTask> selectClaimableTasks(@Param("limit") Integer limit);
|
public List<AiGenerationTask> selectClaimableTasks(@Param("limit") Integer limit);
|
||||||
public int insertAiGenerationTask(AiGenerationTask aiGenerationTask);
|
public int insertAiGenerationTask(AiGenerationTask aiGenerationTask);
|
||||||
public int updateAiGenerationTask(AiGenerationTask aiGenerationTask);
|
public int updateAiGenerationTask(AiGenerationTask aiGenerationTask);
|
||||||
|
public int finishClaimedTask(@Param("task") AiGenerationTask task,
|
||||||
|
@Param("lockedBy") String lockedBy);
|
||||||
|
public int cancelPendingTask(@Param("userId") Long userId, @Param("projectId") Long projectId,
|
||||||
|
@Param("taskId") Long taskId);
|
||||||
|
public int retryFailedTask(@Param("task") AiGenerationTask task);
|
||||||
public int claimTask(@Param("taskId") Long taskId, @Param("lockedBy") String lockedBy);
|
public int claimTask(@Param("taskId") Long taskId, @Param("lockedBy") String lockedBy);
|
||||||
public int renewTaskLock(@Param("taskId") Long taskId, @Param("lockedBy") String lockedBy);
|
public int renewTaskLock(@Param("taskId") Long taskId, @Param("lockedBy") String lockedBy);
|
||||||
public int clearTaskLock(@Param("taskId") Long taskId);
|
public int clearTaskLock(@Param("taskId") Long taskId);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.ruoyi.generator.domain.front.AiQuotaBucket;
|
|||||||
public interface AiQuotaBucketMapper
|
public interface AiQuotaBucketMapper
|
||||||
{
|
{
|
||||||
public AiQuotaBucket selectQuotaBucket(@Param("userId") Long userId, @Param("periodType") String periodType, @Param("periodKey") String periodKey);
|
public AiQuotaBucket selectQuotaBucket(@Param("userId") Long userId, @Param("periodType") String periodType, @Param("periodKey") String periodKey);
|
||||||
|
public AiQuotaBucket selectQuotaBucketForUpdate(@Param("userId") Long userId,
|
||||||
|
@Param("periodType") String periodType, @Param("periodKey") String periodKey);
|
||||||
public int insertQuotaBucket(AiQuotaBucket aiQuotaBucket);
|
public int insertQuotaBucket(AiQuotaBucket aiQuotaBucket);
|
||||||
public int updateQuotaBucket(AiQuotaBucket aiQuotaBucket);
|
public int updateQuotaBucket(AiQuotaBucket aiQuotaBucket);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ public interface FrontProjectMapper
|
|||||||
public int updateFrontProjectIfRevision(FrontProject frontProject);
|
public int updateFrontProjectIfRevision(FrontProject frontProject);
|
||||||
public int deleteFrontProjectById(Long projectId);
|
public int deleteFrontProjectById(Long projectId);
|
||||||
public FrontProject selectFrontProjectByUserAndId(@Param("userId") Long userId, @Param("projectId") Long projectId);
|
public FrontProject selectFrontProjectByUserAndId(@Param("userId") Long userId, @Param("projectId") Long projectId);
|
||||||
|
public FrontProject lockFrontProjectByUserAndId(@Param("userId") Long userId, @Param("projectId") Long projectId);
|
||||||
public int deleteFrontProjectByUserAndId(@Param("userId") Long userId, @Param("projectId") Long projectId);
|
public int deleteFrontProjectByUserAndId(@Param("userId") Long userId, @Param("projectId") Long projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import com.ruoyi.generator.util.TableOperationResolver;
|
|||||||
import com.ruoyi.generator.util.TypedBusinessActionCompiler;
|
import com.ruoyi.generator.util.TypedBusinessActionCompiler;
|
||||||
import com.ruoyi.generator.util.VelocityInitializer;
|
import com.ruoyi.generator.util.VelocityInitializer;
|
||||||
import com.ruoyi.generator.util.VelocityUtils;
|
import com.ruoyi.generator.util.VelocityUtils;
|
||||||
|
import com.ruoyi.generator.util.ZipEntryPathValidator;
|
||||||
import org.apache.velocity.VelocityContext;
|
import org.apache.velocity.VelocityContext;
|
||||||
import org.apache.velocity.app.Velocity;
|
import org.apache.velocity.app.Velocity;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -95,6 +96,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
private static final String ADMIN_INDEX_TEMPLATE_FILE = "admin-index.vue.vm";
|
private static final String ADMIN_INDEX_TEMPLATE_FILE = "admin-index.vue.vm";
|
||||||
private static final String BUNDLED_ADMIN_INDEX_TEMPLATE = "qing/admin-index.vue.vm";
|
private static final String BUNDLED_ADMIN_INDEX_TEMPLATE = "qing/admin-index.vue.vm";
|
||||||
private static final String FRONTEND_PACKAGE_TEMPLATE_FILE = "package.json.vm";
|
private static final String FRONTEND_PACKAGE_TEMPLATE_FILE = "package.json.vm";
|
||||||
|
private static final String BUNDLED_FRONTEND_PACKAGE_TEMPLATE = "qing/vue-package.json.vm";
|
||||||
private static final String ADMIN_MAIN_TEMPLATE_FILE = "admin-main.js.vm";
|
private static final String ADMIN_MAIN_TEMPLATE_FILE = "admin-main.js.vm";
|
||||||
private static final String FRONTEND_MAIN_TEMPLATE_FILE = "frontend-main.js.vm";
|
private static final String FRONTEND_MAIN_TEMPLATE_FILE = "frontend-main.js.vm";
|
||||||
private static final String RESOURCE_URL_PROTOCOL_CHECK =
|
private static final String RESOURCE_URL_PROTOCOL_CHECK =
|
||||||
@@ -262,7 +264,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
|
|
||||||
Map<String, String> metadata = parseArtifactMetadata(artifact.getVariableMetadata());
|
Map<String, String> metadata = parseArtifactMetadata(artifact.getVariableMetadata());
|
||||||
String root = generatedRootName(project, type);
|
String root = generatedRootName(project, type);
|
||||||
String rootEntry = root + "/";
|
String rootEntry = ZipEntryPathValidator.requireRelative(root + "/", "Generated project");
|
||||||
if (zipEntries.add(rootEntry)) {
|
if (zipEntries.add(rootEntry)) {
|
||||||
zip.putNextEntry(new ZipEntry(rootEntry));
|
zip.putNextEntry(new ZipEntry(rootEntry));
|
||||||
zip.closeEntry();
|
zip.closeEntry();
|
||||||
@@ -276,7 +278,8 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
input.closeEntry();
|
input.closeEntry();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String relativePath = replaceArtifactPathVariables(sourcePath, metadata, project, type);
|
String relativePath = ZipEntryPathValidator.requireRelative(
|
||||||
|
replaceArtifactPathVariables(sourcePath, metadata, project, type), "Template skeleton");
|
||||||
String outputPath = root + "/" + relativePath;
|
String outputPath = root + "/" + relativePath;
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
String folderPath = outputPath.endsWith("/") ? outputPath : outputPath + "/";
|
String folderPath = outputPath.endsWith("/") ? outputPath : outputPath + "/";
|
||||||
@@ -303,14 +306,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeSkeletonEntry(String entryName) {
|
private String normalizeSkeletonEntry(String entryName) {
|
||||||
String normalized = StringUtils.defaultString(entryName).replace('\\', '/');
|
return ZipEntryPathValidator.requireRelative(entryName, "Template skeleton");
|
||||||
while (normalized.startsWith("/")) {
|
|
||||||
normalized = normalized.substring(1);
|
|
||||||
}
|
|
||||||
if (normalized.equals("..") || normalized.startsWith("../") || normalized.contains("/../")) {
|
|
||||||
throw new ServiceException("Template skeleton contains an invalid path: " + entryName);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] readZipEntry(ZipInputStream input) throws IOException {
|
private byte[] readZipEntry(ZipInputStream input) throws IOException {
|
||||||
@@ -399,11 +395,18 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
private void processStructureWithPath(List<Map<String, Object>> structure, ZipOutputStream zip, GenProject project,
|
private void processStructureWithPath(List<Map<String, Object>> structure, ZipOutputStream zip, GenProject project,
|
||||||
String currentPath, String type, Set<String> zipEntries) throws IOException {
|
String currentPath, String type, Set<String> zipEntries) throws IOException {
|
||||||
for (Map<String, Object> node : structure) {
|
for (Map<String, Object> node : structure) {
|
||||||
|
if (node == null) {
|
||||||
|
throw new ServiceException("Generated project structure contains an empty node");
|
||||||
|
}
|
||||||
String nodeType = (String) node.get("type");
|
String nodeType = (String) node.get("type");
|
||||||
String name = (String) node.get("name");
|
String name = (String) node.get("name");
|
||||||
|
if (!("folder".equals(nodeType) || "file".equals(nodeType)) || StringUtils.isBlank(name)) {
|
||||||
|
throw new ServiceException("Generated project structure contains an invalid node");
|
||||||
|
}
|
||||||
String category = (String) node.get("category");
|
String category = (String) node.get("category");
|
||||||
Object tableId = node.get("tableId");
|
Object tableId = node.get("tableId");
|
||||||
String fullPath = currentPath + name;
|
String fullPath = ZipEntryPathValidator.requireRelative(currentPath + name,
|
||||||
|
"Generated project structure");
|
||||||
|
|
||||||
if ("folder".equals(nodeType)) {
|
if ("folder".equals(nodeType)) {
|
||||||
String folderPath = fullPath + "/";
|
String folderPath = fullPath + "/";
|
||||||
@@ -422,6 +425,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
if (outputPath == null) {
|
if (outputPath == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
outputPath = ZipEntryPathValidator.requireRelative(outputPath, "Generated project structure");
|
||||||
String content = generateFileContent(project, category, (Long) tableId, type);
|
String content = generateFileContent(project, category, (Long) tableId, type);
|
||||||
if (content != null && zipEntries.add(outputPath)) {
|
if (content != null && zipEntries.add(outputPath)) {
|
||||||
zip.putNextEntry(new ZipEntry(outputPath));
|
zip.putNextEntry(new ZipEntry(outputPath));
|
||||||
@@ -1079,6 +1083,12 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
private String resolveTemplateContent(TemplateFile templateFile) {
|
private String resolveTemplateContent(TemplateFile templateFile) {
|
||||||
String content = upgradeRichTextEditorSupport(templateFile,
|
String content = upgradeRichTextEditorSupport(templateFile,
|
||||||
StringUtils.defaultString(templateFile.getFileContent()));
|
StringUtils.defaultString(templateFile.getFileContent()));
|
||||||
|
if (shouldUseBundledFrontendPackageTemplate(templateFile)) {
|
||||||
|
String bundledContent = readClasspathTemplate(BUNDLED_FRONTEND_PACKAGE_TEMPLATE);
|
||||||
|
if (StringUtils.isNotEmpty(bundledContent)) {
|
||||||
|
return bundledContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (shouldUseBundledBackendControllerTemplate(templateFile, content)) {
|
if (shouldUseBundledBackendControllerTemplate(templateFile, content)) {
|
||||||
String bundledContent = readClasspathTemplate(BUNDLED_BACKEND_CONTROLLER_TEMPLATE);
|
String bundledContent = readClasspathTemplate(BUNDLED_BACKEND_CONTROLLER_TEMPLATE);
|
||||||
if (StringUtils.isNotEmpty(bundledContent)) {
|
if (StringUtils.isNotEmpty(bundledContent)) {
|
||||||
@@ -1148,6 +1158,13 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldUseBundledFrontendPackageTemplate(TemplateFile templateFile) {
|
||||||
|
return templateFile != null
|
||||||
|
&& (RUNNABLE_ADMIN_FRONTEND_TEMPLATE_ID.equals(templateFile.getTemplateId())
|
||||||
|
|| RUNNABLE_FRONTEND_TEMPLATE_ID.equals(templateFile.getTemplateId()))
|
||||||
|
&& matchesTemplateFile(templateFile, FRONTEND_PACKAGE_TEMPLATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
private String upgradeRichTextEditorSupport(TemplateFile templateFile, String content) {
|
private String upgradeRichTextEditorSupport(TemplateFile templateFile, String content) {
|
||||||
if (templateFile == null || (!RUNNABLE_ADMIN_FRONTEND_TEMPLATE_ID.equals(templateFile.getTemplateId())
|
if (templateFile == null || (!RUNNABLE_ADMIN_FRONTEND_TEMPLATE_ID.equals(templateFile.getTemplateId())
|
||||||
&& !RUNNABLE_FRONTEND_TEMPLATE_ID.equals(templateFile.getTemplateId()))) {
|
&& !RUNNABLE_FRONTEND_TEMPLATE_ID.equals(templateFile.getTemplateId()))) {
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
{
|
{
|
||||||
private static final int MAX_ATTEMPTS = 3;
|
private static final int MAX_ATTEMPTS = 3;
|
||||||
private static final int ONE_CLICK_HISTORY_LIMIT = 20;
|
private static final int ONE_CLICK_HISTORY_LIMIT = 20;
|
||||||
|
private static final int MAX_TASK_REQUEST_BYTES = 1024 * 1024;
|
||||||
|
private static final int MAX_PROJECT_NAME_LENGTH = 100;
|
||||||
|
private static final int MAX_PROJECT_DESCRIPTION_LENGTH = 20000;
|
||||||
|
private static final int MAX_OPTION_CODE_LENGTH = 100;
|
||||||
private final OneClickTaskProgressComposer oneClickTaskProgressComposer =
|
private final OneClickTaskProgressComposer oneClickTaskProgressComposer =
|
||||||
new OneClickTaskProgressComposer();
|
new OneClickTaskProgressComposer();
|
||||||
|
|
||||||
@@ -73,10 +77,16 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
@Transactional
|
@Transactional
|
||||||
public AiGenerationTaskStatusResponse createTask(Long userId, Long projectId, AiGenerationTaskCreateRequest request)
|
public AiGenerationTaskStatusResponse createTask(Long userId, Long projectId, AiGenerationTaskCreateRequest request)
|
||||||
{
|
{
|
||||||
FrontProject project = assertOwnedProject(userId, projectId);
|
validateGenerateType(request);
|
||||||
validateRequest(request);
|
FrontProject project = lockOwnedProject(userId, projectId);
|
||||||
|
normalizeOneClickRequest(project, request);
|
||||||
|
validateRequestContent(request);
|
||||||
releaseExpiredRunningTasks();
|
releaseExpiredRunningTasks();
|
||||||
String requestPayload = JSON.toJSONString(request);
|
String requestPayload = JSON.toJSONString(request);
|
||||||
|
if (requestPayload.getBytes(StandardCharsets.UTF_8).length > MAX_TASK_REQUEST_BYTES)
|
||||||
|
{
|
||||||
|
throw new ServiceException("生成请求内容过大");
|
||||||
|
}
|
||||||
AiInvocationPlan invocation = invocationPlan(request.getGenerateType());
|
AiInvocationPlan invocation = invocationPlan(request.getGenerateType());
|
||||||
AiStageManifest stageManifest = stageManifest(request.getGenerateType());
|
AiStageManifest stageManifest = stageManifest(request.getGenerateType());
|
||||||
String stageManifestJson = stageManifest == null ? null : aiStageManifestService.canonical(stageManifest);
|
String stageManifestJson = stageManifest == null ? null : aiStageManifestService.canonical(stageManifest);
|
||||||
@@ -135,6 +145,13 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
public AiGenerationTaskStatusResponse retryTask(Long userId, Long projectId, Long taskId)
|
public AiGenerationTaskStatusResponse retryTask(Long userId, Long projectId, Long taskId)
|
||||||
{
|
{
|
||||||
AiGenerationTask task = selectOwnedTask(userId, projectId, taskId);
|
AiGenerationTask task = selectOwnedTask(userId, projectId, taskId);
|
||||||
|
if (frontProjectMapper.lockFrontProjectByUserAndId(userId, projectId) == null)
|
||||||
|
{
|
||||||
|
throw new ServiceException("项目不存在或无权访问");
|
||||||
|
}
|
||||||
|
// Re-read after acquiring the project lock so concurrent retries cannot
|
||||||
|
// reserve quota and dispatch the same task more than once.
|
||||||
|
task = selectOwnedTask(userId, projectId, taskId);
|
||||||
if (!"FAILED".equals(task.getStatus()) && !"RETRY_WAITING".equals(task.getStatus()))
|
if (!"FAILED".equals(task.getStatus()) && !"RETRY_WAITING".equals(task.getStatus()))
|
||||||
{
|
{
|
||||||
throw new ServiceException("当前任务状态不允许重试");
|
throw new ServiceException("当前任务状态不允许重试");
|
||||||
@@ -158,8 +175,12 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
task.setErrorMessage("");
|
task.setErrorMessage("");
|
||||||
task.setNextRetryTime(new Date());
|
task.setNextRetryTime(new Date());
|
||||||
clearTaskLock(task);
|
clearTaskLock(task);
|
||||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
task.setStartedAt(null);
|
||||||
aiGenerationTaskMapper.clearTaskLock(taskId);
|
task.setFinishedAt(null);
|
||||||
|
if (aiGenerationTaskMapper.retryFailedTask(task) == 0)
|
||||||
|
{
|
||||||
|
throw new ServiceException("当前任务状态不允许重试");
|
||||||
|
}
|
||||||
dispatchAfterCommit(taskId);
|
dispatchAfterCommit(taskId);
|
||||||
return toResponse(task);
|
return toResponse(task);
|
||||||
}
|
}
|
||||||
@@ -173,11 +194,15 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
{
|
{
|
||||||
throw new ServiceException("当前任务状态不允许取消");
|
throw new ServiceException("当前任务状态不允许取消");
|
||||||
}
|
}
|
||||||
|
if (aiGenerationTaskMapper.cancelPendingTask(userId, projectId, taskId) == 0)
|
||||||
|
{
|
||||||
|
throw new ServiceException("当前任务状态不允许取消");
|
||||||
|
}
|
||||||
task.setStatus("CANCELED");
|
task.setStatus("CANCELED");
|
||||||
task.setProgress(0);
|
task.setProgress(0);
|
||||||
task.setCurrentStep("已取消");
|
task.setCurrentStep("已取消");
|
||||||
task.setFinishedAt(new Date());
|
task.setFinishedAt(new Date());
|
||||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
clearTaskLock(task);
|
||||||
aiQuotaService.releaseRunning(userId);
|
aiQuotaService.releaseRunning(userId);
|
||||||
return toResponse(task);
|
return toResponse(task);
|
||||||
}
|
}
|
||||||
@@ -279,7 +304,7 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateRequest(AiGenerationTaskCreateRequest request)
|
private void validateGenerateType(AiGenerationTaskCreateRequest request)
|
||||||
{
|
{
|
||||||
if (request == null || StringUtils.isEmpty(request.getGenerateType()))
|
if (request == null || StringUtils.isEmpty(request.getGenerateType()))
|
||||||
{
|
{
|
||||||
@@ -298,6 +323,59 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void normalizeOneClickRequest(FrontProject project, AiGenerationTaskCreateRequest request)
|
||||||
|
{
|
||||||
|
if (project == null || request == null || !"one_click_project".equals(request.getGenerateType()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.setProjectName(project.getProjectName());
|
||||||
|
request.setProjectDesc(project.getProjectDesc());
|
||||||
|
request.setIndustryTemplate(project.getIndustryTemplate());
|
||||||
|
request.setCodeTemplate(project.getCodeTemplate());
|
||||||
|
request.setStylePreset(project.getStylePreset());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRequestContent(AiGenerationTaskCreateRequest request)
|
||||||
|
{
|
||||||
|
if (!"one_click_project".equals(request.getGenerateType()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireText(request.getProjectName(), "项目名称不能为空", MAX_PROJECT_NAME_LENGTH, "项目名称过长");
|
||||||
|
requireText(request.getStylePreset(), "视觉方案不能为空", MAX_OPTION_CODE_LENGTH, "视觉方案编码过长");
|
||||||
|
requireText(request.getCodeTemplate(), "代码模板不能为空", MAX_OPTION_CODE_LENGTH, "代码模板编码过长");
|
||||||
|
if (StringUtils.defaultString(request.getProjectDesc()).length() > MAX_PROJECT_DESCRIPTION_LENGTH)
|
||||||
|
{
|
||||||
|
throw new ServiceException("项目描述过长");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireText(String value, String emptyMessage, int maxLength, String longMessage)
|
||||||
|
{
|
||||||
|
if (StringUtils.isBlank(value))
|
||||||
|
{
|
||||||
|
throw new ServiceException(emptyMessage);
|
||||||
|
}
|
||||||
|
if (value.trim().length() > maxLength)
|
||||||
|
{
|
||||||
|
throw new ServiceException(longMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FrontProject lockOwnedProject(Long userId, Long projectId)
|
||||||
|
{
|
||||||
|
FrontProject project = assertOwnedProject(userId, projectId);
|
||||||
|
// Serialize task creation/retry per project without relying on an in-process lock.
|
||||||
|
// The database row lock also works when multiple application instances are running.
|
||||||
|
FrontProject lockedProject = frontProjectMapper.lockFrontProjectByUserAndId(userId, projectId);
|
||||||
|
if (lockedProject == null)
|
||||||
|
{
|
||||||
|
throw new ServiceException("项目不存在或无权访问");
|
||||||
|
}
|
||||||
|
return lockedProject;
|
||||||
|
}
|
||||||
|
|
||||||
private Long createGenerationRecord(FrontProject project, Long userId, String generateType,
|
private Long createGenerationRecord(FrontProject project, Long userId, String generateType,
|
||||||
String requestPayload, AiInvocationPlan invocation)
|
String requestPayload, AiInvocationPlan invocation)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,11 +106,16 @@ public class AiGenerationTaskWorker
|
|||||||
task.setProgress(initialProgress(task.getGenerateType()));
|
task.setProgress(initialProgress(task.getGenerateType()));
|
||||||
task.setCurrentStep(initialCurrentStep(task.getGenerateType()));
|
task.setCurrentStep(initialCurrentStep(task.getGenerateType()));
|
||||||
task.setStartedAt(new Date());
|
task.setStartedAt(new Date());
|
||||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
if (aiGenerationTaskMapper.updateAiGenerationTask(task) == 0)
|
||||||
|
{
|
||||||
|
log.warn("AI generation task lease was lost before execution, taskId={}", taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
renewTaskLock(task);
|
renewTaskLock(task);
|
||||||
|
|
||||||
AiUsageScope usageScope = taskUsageScope(task);
|
AiUsageScope usageScope = taskUsageScope(task);
|
||||||
ScheduledFuture<?> heartbeat = startHeartbeat(task);
|
ScheduledFuture<?> heartbeat = startHeartbeat(task);
|
||||||
|
boolean finalized = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Object result = executeWithUsageScope(task, usageScope);
|
Object result = executeWithUsageScope(task, usageScope);
|
||||||
@@ -124,14 +129,22 @@ public class AiGenerationTaskWorker
|
|||||||
applyUsage(task, usageScope);
|
applyUsage(task, usageScope);
|
||||||
int totalTokens = safe(task.getTotalTokens());
|
int totalTokens = safe(task.getTotalTokens());
|
||||||
int costCents = safe(task.getCostCents());
|
int costCents = safe(task.getCostCents());
|
||||||
|
if (!finishClaimedTask(task, lockedBy))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finalized = true;
|
||||||
updateGenerationRecord(task, "1");
|
updateGenerationRecord(task, "1");
|
||||||
clearTaskLock(task);
|
|
||||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
|
||||||
clearTaskLockInStore(task);
|
|
||||||
insertEstimatedLedgerIfNeeded(task, usageScope, "1");
|
insertEstimatedLedgerIfNeeded(task, usageScope, "1");
|
||||||
aiQuotaService.settleCost(task.getUserId(), totalTokens, costCents);
|
aiQuotaService.settleCost(task.getUserId(), totalTokens, costCents);
|
||||||
}
|
}
|
||||||
catch (RuntimeException e)
|
catch (RuntimeException e)
|
||||||
|
{
|
||||||
|
if (finalized)
|
||||||
|
{
|
||||||
|
log.error("AI generation task post-finalization processing failed, taskId={}", taskId, e);
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
String message = sanitize(e.getMessage());
|
String message = sanitize(e.getMessage());
|
||||||
task.setErrorMessage(message);
|
task.setErrorMessage(message);
|
||||||
@@ -149,27 +162,43 @@ public class AiGenerationTaskWorker
|
|||||||
task.setStatus("FAILED");
|
task.setStatus("FAILED");
|
||||||
task.setCurrentStep("生成失败");
|
task.setCurrentStep("生成失败");
|
||||||
task.setFinishedAt(new Date());
|
task.setFinishedAt(new Date());
|
||||||
|
}
|
||||||
|
if (!finishClaimedTask(task, lockedBy))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finalized = true;
|
||||||
|
if ("FAILED".equals(task.getStatus()))
|
||||||
|
{
|
||||||
updateGenerationRecord(task, "0");
|
updateGenerationRecord(task, "0");
|
||||||
}
|
}
|
||||||
clearTaskLock(task);
|
|
||||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
|
||||||
clearTaskLockInStore(task);
|
|
||||||
insertEstimatedLedgerIfNeeded(task, usageScope, "0");
|
insertEstimatedLedgerIfNeeded(task, usageScope, "0");
|
||||||
if (safe(task.getTotalTokens()) > 0 || safe(task.getCostCents()) > 0)
|
if (safe(task.getTotalTokens()) > 0 || safe(task.getCostCents()) > 0)
|
||||||
{
|
{
|
||||||
aiQuotaService.settleCost(task.getUserId(), safe(task.getTotalTokens()), safe(task.getCostCents()));
|
aiQuotaService.settleCost(task.getUserId(), safe(task.getTotalTokens()), safe(task.getCostCents()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (Throwable e)
|
catch (Throwable e)
|
||||||
{
|
{
|
||||||
handleFailure(task, usageScope, attempts, e);
|
if (finalized)
|
||||||
|
{
|
||||||
|
log.error("AI generation task post-finalization processing failed, taskId={}", taskId, e);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
finalized = handleFailure(task, usageScope, attempts, lockedBy, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
cancelHeartbeat(heartbeat);
|
cancelHeartbeat(heartbeat);
|
||||||
|
if (finalized)
|
||||||
|
{
|
||||||
aiQuotaService.releaseRunning(task.getUserId());
|
aiQuotaService.releaseRunning(task.getUserId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int processDueTasks(int limit)
|
public int processDueTasks(int limit)
|
||||||
{
|
{
|
||||||
@@ -188,7 +217,8 @@ public class AiGenerationTaskWorker
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFailure(AiGenerationTask task, AiUsageScope usageScope, int attempts, Throwable error)
|
private boolean handleFailure(AiGenerationTask task, AiUsageScope usageScope, int attempts,
|
||||||
|
String lockedBy, Throwable error)
|
||||||
{
|
{
|
||||||
String message = sanitize(error);
|
String message = sanitize(error);
|
||||||
task.setErrorMessage(message);
|
task.setErrorMessage(message);
|
||||||
@@ -210,11 +240,15 @@ public class AiGenerationTaskWorker
|
|||||||
task.setStatus("FAILED");
|
task.setStatus("FAILED");
|
||||||
task.setCurrentStep("Generation failed");
|
task.setCurrentStep("Generation failed");
|
||||||
task.setFinishedAt(new Date());
|
task.setFinishedAt(new Date());
|
||||||
|
}
|
||||||
|
if (!finishClaimedTask(task, lockedBy))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("FAILED".equals(task.getStatus()))
|
||||||
|
{
|
||||||
updateGenerationRecord(task, "0");
|
updateGenerationRecord(task, "0");
|
||||||
}
|
}
|
||||||
clearTaskLock(task);
|
|
||||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
|
||||||
clearTaskLockInStore(task);
|
|
||||||
insertEstimatedLedgerIfNeeded(task, usageScope, "0");
|
insertEstimatedLedgerIfNeeded(task, usageScope, "0");
|
||||||
if (safe(task.getTotalTokens()) > 0 || safe(task.getCostCents()) > 0)
|
if (safe(task.getTotalTokens()) > 0 || safe(task.getCostCents()) > 0)
|
||||||
{
|
{
|
||||||
@@ -224,6 +258,7 @@ public class AiGenerationTaskWorker
|
|||||||
{
|
{
|
||||||
log.error("AI generation task failed with non-runtime error, taskId={}", task.getTaskId(), error);
|
log.error("AI generation task failed with non-runtime error, taskId={}", task.getTaskId(), error);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void releaseExpiredRunningTasks()
|
private void releaseExpiredRunningTasks()
|
||||||
@@ -566,23 +601,21 @@ public class AiGenerationTaskWorker
|
|||||||
generationRecordService.updateTaskResult(task, success);
|
generationRecordService.updateTaskResult(task, success);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearTaskLock(AiGenerationTask task)
|
private boolean finishClaimedTask(AiGenerationTask task, String lockedBy)
|
||||||
{
|
{
|
||||||
if (task == null)
|
if (task == null || task.getTaskId() == null || StringUtils.isBlank(lockedBy))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
|
}
|
||||||
|
if (aiGenerationTaskMapper.finishClaimedTask(task, lockedBy) == 0)
|
||||||
|
{
|
||||||
|
log.warn("Ignored stale AI generation task completion, taskId={}, lockedBy={}",
|
||||||
|
task.getTaskId(), lockedBy);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
task.setLockedBy(null);
|
task.setLockedBy(null);
|
||||||
task.setLockedUntil(null);
|
task.setLockedUntil(null);
|
||||||
}
|
return true;
|
||||||
|
|
||||||
private void clearTaskLockInStore(AiGenerationTask task)
|
|
||||||
{
|
|
||||||
if (task == null || task.getTaskId() == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
aiGenerationTaskMapper.clearTaskLock(task.getTaskId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private GenerateAppBlueprintRequest toAppBlueprintRequest(AiGenerationTaskCreateRequest source)
|
private GenerateAppBlueprintRequest toAppBlueprintRequest(AiGenerationTaskCreateRequest source)
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ public class AiQuotaService
|
|||||||
|
|
||||||
private AiQuotaBucket ensureBucket(Long userId, String periodType, String periodKey)
|
private AiQuotaBucket ensureBucket(Long userId, String periodType, String periodKey)
|
||||||
{
|
{
|
||||||
AiQuotaBucket bucket = aiQuotaBucketMapper.selectQuotaBucket(userId, periodType, periodKey);
|
AiQuotaBucket bucket = aiQuotaBucketMapper.selectQuotaBucketForUpdate(userId, periodType, periodKey);
|
||||||
if (bucket != null)
|
if (bucket != null)
|
||||||
{
|
{
|
||||||
return bucket;
|
return bucket;
|
||||||
@@ -121,7 +121,8 @@ public class AiQuotaService
|
|||||||
bucket.setRunningLimit("DAY".equals(periodType) ? RUNNING_LIMIT : 0);
|
bucket.setRunningLimit("DAY".equals(periodType) ? RUNNING_LIMIT : 0);
|
||||||
bucket.setRunningCount(0);
|
bucket.setRunningCount(0);
|
||||||
aiQuotaBucketMapper.insertQuotaBucket(bucket);
|
aiQuotaBucketMapper.insertQuotaBucket(bucket);
|
||||||
return bucket;
|
AiQuotaBucket locked = aiQuotaBucketMapper.selectQuotaBucketForUpdate(userId, periodType, periodKey);
|
||||||
|
return locked == null ? bucket : locked;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reconcileRunningCount(Long userId, AiQuotaBucket day)
|
private void reconcileRunningCount(Long userId, AiQuotaBucket day)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.ruoyi.generator.service.IGenProjectService;
|
|||||||
import com.ruoyi.generator.service.ITemplateBundleService;
|
import com.ruoyi.generator.service.ITemplateBundleService;
|
||||||
import com.ruoyi.common.exception.ServiceException;
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
|
import com.ruoyi.generator.util.ZipEntryPathValidator;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -376,15 +377,6 @@ public class FrontProjectPreviewServiceImpl implements IFrontProjectPreviewServi
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String normalized = entryName.replace('\\', '/');
|
return ZipEntryPathValidator.requireRelative(entryName, "项目源码压缩包");
|
||||||
while (normalized.startsWith("/"))
|
|
||||||
{
|
|
||||||
normalized = normalized.substring(1);
|
|
||||||
}
|
|
||||||
if (normalized.startsWith("../") || normalized.contains("/../"))
|
|
||||||
{
|
|
||||||
throw new ServiceException("项目源码压缩包路径不合法");
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public class FrontProjectServiceImpl implements IFrontProjectService
|
|||||||
{
|
{
|
||||||
private static final Pattern SAFE_PROJECT_FILE_NAME = Pattern.compile("^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$");
|
private static final Pattern SAFE_PROJECT_FILE_NAME = Pattern.compile("^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$");
|
||||||
private static final Pattern DB_NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9_]{1,63}$");
|
private static final Pattern DB_NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9_]{1,63}$");
|
||||||
|
private static final Pattern GENERATOR_IDENTIFIER_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$");
|
||||||
private static final Pattern BIGINT_TYPE_PATTERN = Pattern.compile("^bigint(\\(20\\))?$");
|
private static final Pattern BIGINT_TYPE_PATTERN = Pattern.compile("^bigint(\\(20\\))?$");
|
||||||
private static final Pattern INT_TYPE_PATTERN = Pattern.compile("^int(\\(11\\))?$");
|
private static final Pattern INT_TYPE_PATTERN = Pattern.compile("^int(\\(11\\))?$");
|
||||||
private static final Pattern VARCHAR_TYPE_PATTERN = Pattern.compile("^varchar\\((\\d{1,5})\\)$");
|
private static final Pattern VARCHAR_TYPE_PATTERN = Pattern.compile("^varchar\\((\\d{1,5})\\)$");
|
||||||
@@ -592,6 +593,15 @@ public class FrontProjectServiceImpl implements IFrontProjectService
|
|||||||
{
|
{
|
||||||
throw new ServiceException("Table name duplicated: " + table.getTableName());
|
throw new ServiceException("Table name duplicated: " + table.getTableName());
|
||||||
}
|
}
|
||||||
|
if (StringUtils.isNotBlank(table.getModuleName())
|
||||||
|
&& !GENERATOR_IDENTIFIER_PATTERN.matcher(table.getModuleName()).matches())
|
||||||
|
{
|
||||||
|
throw new ServiceException("模块名只能包含字母、数字和下划线,且必须以字母开头");
|
||||||
|
}
|
||||||
|
if (!GENERATOR_IDENTIFIER_PATTERN.matcher(table.getBusinessName()).matches())
|
||||||
|
{
|
||||||
|
throw new ServiceException("业务名只能包含字母、数字和下划线,且必须以字母开头");
|
||||||
|
}
|
||||||
if (table.getColumns() == null || table.getColumns().isEmpty())
|
if (table.getColumns() == null || table.getColumns().isEmpty())
|
||||||
{
|
{
|
||||||
throw new ServiceException("每张表至少需要一个字段");
|
throw new ServiceException("每张表至少需要一个字段");
|
||||||
@@ -660,7 +670,8 @@ public class FrontProjectServiceImpl implements IFrontProjectService
|
|||||||
}
|
}
|
||||||
table.setTableName(trimToNull(table.getTableName()));
|
table.setTableName(trimToNull(table.getTableName()));
|
||||||
table.setClassName(StringUtils.convertToCamelCase(table.getTableName()));
|
table.setClassName(StringUtils.convertToCamelCase(table.getTableName()));
|
||||||
table.setBusinessName(StringUtils.defaultIfEmpty(table.getBusinessName(), table.getTableName()));
|
table.setModuleName(trimToNull(table.getModuleName()));
|
||||||
|
table.setBusinessName(StringUtils.defaultIfEmpty(trimToNull(table.getBusinessName()), table.getTableName()));
|
||||||
table.setFunctionName(StringUtils.defaultIfEmpty(table.getFunctionName(),
|
table.setFunctionName(StringUtils.defaultIfEmpty(table.getFunctionName(),
|
||||||
StringUtils.defaultIfEmpty(table.getTableComment(), table.getTableName())));
|
StringUtils.defaultIfEmpty(table.getTableComment(), table.getTableName())));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.ruoyi.generator.util;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
|
|
||||||
|
/** Validates generated ZIP entry paths for portable, relative extraction. */
|
||||||
|
public final class ZipEntryPathValidator
|
||||||
|
{
|
||||||
|
private static final int MAX_ENTRY_PATH_LENGTH = 1024;
|
||||||
|
private static final String WINDOWS_RESERVED_CHARACTERS = "<>:\"|?*";
|
||||||
|
|
||||||
|
private ZipEntryPathValidator()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String requireRelative(String entryName, String sourceLabel)
|
||||||
|
{
|
||||||
|
String normalized = StringUtils.defaultString(entryName).replace('\\', '/');
|
||||||
|
if (normalized.length() == 0 || normalized.length() > MAX_ENTRY_PATH_LENGTH
|
||||||
|
|| normalized.startsWith("/") || hasControlCharacter(normalized))
|
||||||
|
{
|
||||||
|
throw invalid(sourceLabel, entryName);
|
||||||
|
}
|
||||||
|
boolean directory = normalized.endsWith("/");
|
||||||
|
String path = directory ? normalized.substring(0, normalized.length() - 1) : normalized;
|
||||||
|
if (path.length() == 0)
|
||||||
|
{
|
||||||
|
throw invalid(sourceLabel, entryName);
|
||||||
|
}
|
||||||
|
String[] segments = path.split("/", -1);
|
||||||
|
for (String segment : segments)
|
||||||
|
{
|
||||||
|
if (segment.length() == 0 || ".".equals(segment) || "..".equals(segment)
|
||||||
|
|| containsWindowsReservedCharacter(segment) || segment.endsWith(" ")
|
||||||
|
|| segment.endsWith(".") || isWindowsDeviceName(segment))
|
||||||
|
{
|
||||||
|
throw invalid(sourceLabel, entryName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directory ? path + "/" : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasControlCharacter(String value)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < value.length(); i++)
|
||||||
|
{
|
||||||
|
if (Character.isISOControl(value.charAt(i)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWindowsDeviceName(String segment)
|
||||||
|
{
|
||||||
|
String upper = segment.toUpperCase(Locale.ROOT);
|
||||||
|
int extension = upper.indexOf('.');
|
||||||
|
String baseName = extension < 0 ? upper : upper.substring(0, extension);
|
||||||
|
if ("CON".equals(baseName) || "PRN".equals(baseName) || "AUX".equals(baseName)
|
||||||
|
|| "NUL".equals(baseName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return baseName.matches("COM[1-9]") || baseName.matches("LPT[1-9]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsWindowsReservedCharacter(String segment)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < segment.length(); i++)
|
||||||
|
{
|
||||||
|
if (WINDOWS_RESERVED_CHARACTERS.indexOf(segment.charAt(i)) >= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceException invalid(String sourceLabel, String entryName)
|
||||||
|
{
|
||||||
|
String value = StringUtils.defaultString(entryName);
|
||||||
|
String display = value.length() <= 160 ? value : value.substring(0, 160) + "...";
|
||||||
|
return new ServiceException(StringUtils.defaultIfEmpty(sourceLabel, "ZIP entry")
|
||||||
|
+ " contains an invalid path: " + display);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
set status = 'SUCCEEDED', finished_at = #{finishedAt}, duration_millis = #{durationMillis},
|
set status = 'SUCCEEDED', finished_at = #{finishedAt}, duration_millis = #{durationMillis},
|
||||||
error_message = '', update_time = now()
|
error_message = '', update_time = now()
|
||||||
where task_id = #{taskId} and stage_code = #{stageCode} and attempt_no = #{attemptNo}
|
where task_id = #{taskId} and stage_code = #{stageCode} and attempt_no = #{attemptNo}
|
||||||
|
and status = 'RUNNING'
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<update id="failStageAttempt">
|
<update id="failStageAttempt">
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
set status = 'FAILED', finished_at = #{finishedAt}, duration_millis = #{durationMillis},
|
set status = 'FAILED', finished_at = #{finishedAt}, duration_millis = #{durationMillis},
|
||||||
error_message = #{errorMessage}, update_time = now()
|
error_message = #{errorMessage}, update_time = now()
|
||||||
where task_id = #{taskId} and stage_code = #{stageCode} and attempt_no = #{attemptNo}
|
where task_id = #{taskId} and stage_code = #{stageCode} and attempt_no = #{attemptNo}
|
||||||
|
and status = 'RUNNING'
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<update id="linkGenerationRun">
|
<update id="linkGenerationRun">
|
||||||
|
|||||||
@@ -275,6 +275,73 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
update_time = sysdate(),
|
update_time = sysdate(),
|
||||||
</trim>
|
</trim>
|
||||||
where task_id = #{taskId}
|
where task_id = #{taskId}
|
||||||
|
<if test="status == 'RUNNING' and lockedBy != null and lockedBy != ''">
|
||||||
|
and status = 'RUNNING'
|
||||||
|
and locked_by = #{lockedBy}
|
||||||
|
</if>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="finishClaimedTask">
|
||||||
|
update front_ai_generation_task
|
||||||
|
<trim prefix="SET" suffixOverrides=",">
|
||||||
|
<if test="task.generationId != null">generation_id = #{task.generationId},</if>
|
||||||
|
<if test="task.status != null">status = #{task.status},</if>
|
||||||
|
<if test="task.resultPayload != null">result_payload = #{task.resultPayload},</if>
|
||||||
|
<if test="task.errorCode != null">error_code = #{task.errorCode},</if>
|
||||||
|
<if test="task.errorMessage != null">error_message = #{task.errorMessage},</if>
|
||||||
|
<if test="task.attempts != null">attempts = #{task.attempts},</if>
|
||||||
|
<if test="task.maxAttempts != null">max_attempts = #{task.maxAttempts},</if>
|
||||||
|
<if test="task.nextRetryTime != null">next_retry_time = #{task.nextRetryTime},</if>
|
||||||
|
<if test="task.progress != null">progress = #{task.progress},</if>
|
||||||
|
<if test="task.currentStep != null">current_step = #{task.currentStep},</if>
|
||||||
|
<if test="task.inputTokens != null">input_tokens = #{task.inputTokens},</if>
|
||||||
|
<if test="task.outputTokens != null">output_tokens = #{task.outputTokens},</if>
|
||||||
|
<if test="task.totalTokens != null">total_tokens = #{task.totalTokens},</if>
|
||||||
|
<if test="task.costCents != null">cost_cents = #{task.costCents},</if>
|
||||||
|
<if test="task.startedAt != null">started_at = #{task.startedAt},</if>
|
||||||
|
<if test="task.finishedAt != null">finished_at = #{task.finishedAt},</if>
|
||||||
|
locked_by = null,
|
||||||
|
locked_until = null,
|
||||||
|
update_time = sysdate(),
|
||||||
|
</trim>
|
||||||
|
where task_id = #{task.taskId}
|
||||||
|
and status = 'RUNNING'
|
||||||
|
and locked_by = #{lockedBy}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="cancelPendingTask">
|
||||||
|
update front_ai_generation_task
|
||||||
|
set status = 'CANCELED',
|
||||||
|
progress = 0,
|
||||||
|
current_step = '已取消',
|
||||||
|
locked_by = null,
|
||||||
|
locked_until = null,
|
||||||
|
finished_at = sysdate(),
|
||||||
|
update_time = sysdate()
|
||||||
|
where task_id = #{taskId}
|
||||||
|
and user_id = #{userId}
|
||||||
|
and project_id = #{projectId}
|
||||||
|
and status in ('QUEUED', 'RETRY_WAITING')
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="retryFailedTask">
|
||||||
|
update front_ai_generation_task
|
||||||
|
set status = 'QUEUED',
|
||||||
|
progress = 0,
|
||||||
|
current_step = #{task.currentStep},
|
||||||
|
result_payload = #{task.resultPayload},
|
||||||
|
error_code = '',
|
||||||
|
error_message = '',
|
||||||
|
next_retry_time = #{task.nextRetryTime},
|
||||||
|
started_at = null,
|
||||||
|
finished_at = null,
|
||||||
|
locked_by = null,
|
||||||
|
locked_until = null,
|
||||||
|
update_time = sysdate()
|
||||||
|
where task_id = #{task.taskId}
|
||||||
|
and user_id = #{task.userId}
|
||||||
|
and project_id = #{task.projectId}
|
||||||
|
and status in ('FAILED', 'RETRY_WAITING')
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<update id="claimTask">
|
<update id="claimTask">
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
where user_id = #{userId} and period_type = #{periodType} and period_key = #{periodKey}
|
where user_id = #{userId} and period_type = #{periodType} and period_key = #{periodKey}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="selectQuotaBucketForUpdate" resultMap="AiQuotaBucketResult">
|
||||||
|
select bucket_id, user_id, period_type, period_key, task_limit, task_used, token_limit, token_used,
|
||||||
|
cost_limit_cents, cost_used_cents, running_limit, running_count, create_time, update_time
|
||||||
|
from front_ai_quota_bucket
|
||||||
|
where user_id = #{userId} and period_type = #{periodType} and period_key = #{periodKey}
|
||||||
|
for update
|
||||||
|
</select>
|
||||||
|
|
||||||
<insert id="insertQuotaBucket" parameterType="AiQuotaBucket" useGeneratedKeys="true" keyProperty="bucketId">
|
<insert id="insertQuotaBucket" parameterType="AiQuotaBucket" useGeneratedKeys="true" keyProperty="bucketId">
|
||||||
insert into front_ai_quota_bucket
|
insert into front_ai_quota_bucket
|
||||||
(user_id, period_type, period_key, task_limit, task_used, token_limit, token_used,
|
(user_id, period_type, period_key, task_limit, task_used, token_limit, token_used,
|
||||||
@@ -35,6 +43,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
values
|
values
|
||||||
(#{userId}, #{periodType}, #{periodKey}, #{taskLimit}, #{taskUsed}, #{tokenLimit}, #{tokenUsed},
|
(#{userId}, #{periodType}, #{periodKey}, #{taskLimit}, #{taskUsed}, #{tokenLimit}, #{tokenUsed},
|
||||||
#{costLimitCents}, #{costUsedCents}, #{runningLimit}, #{runningCount}, sysdate(), sysdate())
|
#{costLimitCents}, #{costUsedCents}, #{runningLimit}, #{runningCount}, sysdate(), sysdate())
|
||||||
|
on duplicate key update bucket_id = last_insert_id(bucket_id)
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateQuotaBucket" parameterType="AiQuotaBucket">
|
<update id="updateQuotaBucket" parameterType="AiQuotaBucket">
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
where user_id = #{userId} and project_id = #{projectId}
|
where user_id = #{userId} and project_id = #{projectId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="lockFrontProjectByUserAndId" resultMap="FrontProjectResult">
|
||||||
|
<include refid="selectFrontProjectVo"/>
|
||||||
|
where user_id = #{userId} and project_id = #{projectId}
|
||||||
|
for update
|
||||||
|
</select>
|
||||||
|
|
||||||
<select id="selectFrontProjectList" parameterType="FrontProject" resultMap="FrontProjectResult">
|
<select id="selectFrontProjectList" parameterType="FrontProject" resultMap="FrontProjectResult">
|
||||||
<include refid="selectFrontProjectVo"/>
|
<include refid="selectFrontProjectVo"/>
|
||||||
<where>
|
<where>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vue-cli-service serve --host 0.0.0.0",
|
"dev": "vue-cli-service serve --host 0.0.0.0",
|
||||||
"serve": "vue-cli-service serve --host 0.0.0.0",
|
"serve": "vue-cli-service serve --host 0.0.0.0",
|
||||||
|
"lint": "vue-cli-service lint --no-fix",
|
||||||
"build": "vue-cli-service build"
|
"build": "vue-cli-service build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,7 +19,23 @@
|
|||||||
"vue-router": "^3.6.5"
|
"vue-router": "^3.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-eslint": "^4.5.19",
|
||||||
"@vue/cli-service": "^4.5.19",
|
"@vue/cli-service": "^4.5.19",
|
||||||
|
"babel-eslint": "^10.1.0",
|
||||||
|
"eslint": "^6.7.2",
|
||||||
|
"eslint-plugin-vue": "^6.2.2",
|
||||||
"vue-template-compiler": "^2.6.14"
|
"vue-template-compiler": "^2.6.14"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:vue/essential"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"parser": "babel-eslint"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.ruoyi.generator.factory.ai.stage.oneclick;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import com.ruoyi.generator.domain.front.AiGenerationTask;
|
||||||
|
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
|
||||||
|
import com.ruoyi.generator.domain.front.dto.OneClickProjectGenerationResult;
|
||||||
|
import com.ruoyi.generator.factory.ai.stage.AiTaskCheckpointService;
|
||||||
|
import com.ruoyi.generator.factory.ai.stage.GenerationStagePipeline;
|
||||||
|
import com.ruoyi.generator.factory.generation.ProjectSpecGenerationService;
|
||||||
|
import com.ruoyi.generator.factory.generation.model.ProjectSpecGenerationResult;
|
||||||
|
import com.ruoyi.generator.factory.version.ProjectSpecVersionService;
|
||||||
|
import com.ruoyi.generator.factory.version.model.ProjectSpecVersionView;
|
||||||
|
import com.ruoyi.generator.service.front.FrontendPageDesignService;
|
||||||
|
import com.ruoyi.generator.service.front.IFrontProjectPreviewService;
|
||||||
|
|
||||||
|
public class GenerateStageHandlerTest
|
||||||
|
{
|
||||||
|
private IFrontProjectPreviewService previewService;
|
||||||
|
private AiTaskCheckpointService checkpointService;
|
||||||
|
private GenerateStageHandler handler;
|
||||||
|
private OneClickGenerationContext context;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp()
|
||||||
|
{
|
||||||
|
FrontendPageDesignService pageDesignService = mock(FrontendPageDesignService.class);
|
||||||
|
OneClickStageSupport support = mock(OneClickStageSupport.class);
|
||||||
|
ProjectSpecVersionService versionService = mock(ProjectSpecVersionService.class);
|
||||||
|
ProjectSpecGenerationService generationService = mock(ProjectSpecGenerationService.class);
|
||||||
|
previewService = mock(IFrontProjectPreviewService.class);
|
||||||
|
GenerationStagePipeline stagePipeline = mock(GenerationStagePipeline.class);
|
||||||
|
checkpointService = mock(AiTaskCheckpointService.class);
|
||||||
|
handler = new GenerateStageHandler(pageDesignService, support, versionService,
|
||||||
|
generationService, previewService, stagePipeline, checkpointService);
|
||||||
|
|
||||||
|
ProjectSpecVersionView version = new ProjectSpecVersionView();
|
||||||
|
version.setSpecVersionId(11L);
|
||||||
|
version.setVersionNo(1);
|
||||||
|
version.setContentHash("spec-hash");
|
||||||
|
when(versionService.snapshotCurrent(anyLong(), anyLong(), anyString(), anyString()))
|
||||||
|
.thenReturn(version);
|
||||||
|
ProjectSpecGenerationResult generated = new ProjectSpecGenerationResult();
|
||||||
|
generated.setGenerationRunId(22L);
|
||||||
|
when(generationService.generate(7L, 20L, 11L)).thenReturn(generated);
|
||||||
|
|
||||||
|
AiGenerationTask task = new AiGenerationTask();
|
||||||
|
task.setTaskId(99L);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setProjectId(20L);
|
||||||
|
task.setAttempts(1);
|
||||||
|
context = new OneClickGenerationContext(task, new AiGenerationTaskCreateRequest(),
|
||||||
|
new OneClickProjectGenerationResult());
|
||||||
|
context.setFrontendEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void rejectsTemplateBundleWithoutAnyGeneratedProjectType()
|
||||||
|
{
|
||||||
|
when(previewService.getSupportedTemplateTypes(7L, 20L)).thenReturn(Collections.<String>emptyList());
|
||||||
|
|
||||||
|
ServiceException error = expectFailure();
|
||||||
|
|
||||||
|
assertEquals("代码模板没有可生成的项目类型", error.getMessage());
|
||||||
|
assertFalse(context.getResult().isDownloadReady());
|
||||||
|
verify(previewService, never()).markPreviewReady(anyLong(), anyLong());
|
||||||
|
verify(checkpointService, never()).save(any(), anyString(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void rejectsMissingGeneratedStructureInsteadOfReturningDownloadReady()
|
||||||
|
{
|
||||||
|
when(previewService.getSupportedTemplateTypes(7L, 20L))
|
||||||
|
.thenReturn(Collections.singletonList("backend"));
|
||||||
|
when(previewService.getStructure(7L, 20L, "backend"))
|
||||||
|
.thenReturn(Collections.<java.util.Map<String, Object>>emptyList());
|
||||||
|
|
||||||
|
ServiceException error = expectFailure();
|
||||||
|
|
||||||
|
assertEquals("生成结果缺少项目结构: backend", error.getMessage());
|
||||||
|
assertFalse(context.getResult().isDownloadReady());
|
||||||
|
verify(previewService, never()).markPreviewReady(anyLong(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void rejectsFailedPreviewReadyPersistence()
|
||||||
|
{
|
||||||
|
when(previewService.getSupportedTemplateTypes(7L, 20L))
|
||||||
|
.thenReturn(Collections.singletonList("backend"));
|
||||||
|
when(previewService.getStructure(7L, 20L, "backend"))
|
||||||
|
.thenReturn(Collections.singletonList(Collections.<String, Object>singletonMap("name", "backend")));
|
||||||
|
when(previewService.markPreviewReady(7L, 20L)).thenReturn(0);
|
||||||
|
|
||||||
|
ServiceException error = expectFailure();
|
||||||
|
|
||||||
|
assertEquals("无法标记项目源码为可预览状态", error.getMessage());
|
||||||
|
assertFalse(context.getResult().isDownloadReady());
|
||||||
|
verify(checkpointService, never()).save(any(), anyString(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServiceException expectFailure()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
handler.execute(context);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected ServiceException");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ public class RunPreviewStageHandlerTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void marksStageFailedWhenPreviewNeverBecomesRunning()
|
public void throwsAfterPersistingFailureWhenPreviewNeverBecomesRunning()
|
||||||
{
|
{
|
||||||
IFrontProjectRunPreviewService runPreviewService = mock(IFrontProjectRunPreviewService.class);
|
IFrontProjectRunPreviewService runPreviewService = mock(IFrontProjectRunPreviewService.class);
|
||||||
AiTaskCheckpointService checkpointService = mock(AiTaskCheckpointService.class);
|
AiTaskCheckpointService checkpointService = mock(AiTaskCheckpointService.class);
|
||||||
@@ -63,8 +63,18 @@ public class RunPreviewStageHandlerTest
|
|||||||
when(runPreviewService.start(7L, 20L)).thenReturn(status(ProjectRunPreviewStatus.STARTING));
|
when(runPreviewService.start(7L, 20L)).thenReturn(status(ProjectRunPreviewStatus.STARTING));
|
||||||
when(runPreviewService.status(7L, 20L)).thenReturn(status(ProjectRunPreviewStatus.STARTING));
|
when(runPreviewService.status(7L, 20L)).thenReturn(status(ProjectRunPreviewStatus.STARTING));
|
||||||
|
|
||||||
|
ServiceException failure = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
handler.execute(context);
|
handler.execute(context);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
org.junit.Assert.assertNotNull(failure);
|
||||||
|
assertEquals("运行预览启动超时", failure.getMessage());
|
||||||
assertEquals(ProjectRunPreviewStatus.FAILED, context.getResult().getPreviewStatus());
|
assertEquals(ProjectRunPreviewStatus.FAILED, context.getResult().getPreviewStatus());
|
||||||
assertEquals(OneClickProjectGenerationResult.STAGE_VERIFY_AND_PREVIEW,
|
assertEquals(OneClickProjectGenerationResult.STAGE_VERIFY_AND_PREVIEW,
|
||||||
context.getResult().getFailedStage());
|
context.getResult().getFailedStage());
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
package com.ruoyi.generator.service;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import com.ruoyi.generator.domain.GenProject;
|
||||||
|
import com.ruoyi.generator.domain.GenTable;
|
||||||
|
import com.ruoyi.generator.domain.GenTableColumn;
|
||||||
|
import com.ruoyi.generator.domain.SysCodeSnippet;
|
||||||
|
import com.ruoyi.generator.domain.SysProjectModule;
|
||||||
|
import com.ruoyi.generator.domain.SysProjectStructure;
|
||||||
|
import com.ruoyi.generator.domain.Template;
|
||||||
|
import com.ruoyi.generator.domain.TemplateBundle;
|
||||||
|
import com.ruoyi.generator.domain.TemplateFile;
|
||||||
|
import com.ruoyi.generator.domain.front.FrontProjectPageDesign;
|
||||||
|
import com.ruoyi.generator.mapper.GenProjectMapper;
|
||||||
|
import com.ruoyi.generator.mapper.GenTableMapper;
|
||||||
|
import com.ruoyi.generator.service.front.BusinessBlockRegistryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline generation acceptance matrix for the bundled Qing templates.
|
||||||
|
* Each representative project is generated three times for every runnable
|
||||||
|
* source type and compared by entry content rather than ZIP timestamps.
|
||||||
|
*/
|
||||||
|
public class QingGenerationMatrixTest
|
||||||
|
{
|
||||||
|
private static final long BACKEND_TEMPLATE_ID = 9201L;
|
||||||
|
private static final long ADMIN_TEMPLATE_ID = 9202L;
|
||||||
|
private static final long FRONTEND_TEMPLATE_ID = 9203L;
|
||||||
|
private static final List<String> TYPES = Arrays.asList("backend", "admin_frontend", "frontend");
|
||||||
|
|
||||||
|
private GenProjectServiceImpl service;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() throws Exception
|
||||||
|
{
|
||||||
|
service = new GenProjectServiceImpl();
|
||||||
|
setField(service, "genProjectMapper", mock(GenProjectMapper.class));
|
||||||
|
setField(service, "genTableMapper", mock(GenTableMapper.class));
|
||||||
|
|
||||||
|
ITemplateService templateService = mock(ITemplateService.class);
|
||||||
|
when(templateService.selectTemplateList(any(Template.class))).thenAnswer(invocation -> {
|
||||||
|
Template filter = invocation.getArgument(0);
|
||||||
|
return Collections.singletonList(template(templateId(filter.getTemplateType()), filter.getTemplateType()));
|
||||||
|
});
|
||||||
|
setField(service, "templateService", templateService);
|
||||||
|
|
||||||
|
ITemplateBundleService bundleService = mock(ITemplateBundleService.class);
|
||||||
|
when(bundleService.getDefaultBundle()).thenReturn(bundle());
|
||||||
|
when(bundleService.getRequiredEnabledBundle("qing")).thenReturn(bundle());
|
||||||
|
setField(service, "templateBundleService", bundleService);
|
||||||
|
|
||||||
|
ITemplateFileService templateFileService = mock(ITemplateFileService.class);
|
||||||
|
when(templateFileService.selectTemplateFilesByTemplateId(BACKEND_TEMPLATE_ID)).thenReturn(backendFiles());
|
||||||
|
when(templateFileService.selectTemplateFilesByTemplateId(ADMIN_TEMPLATE_ID)).thenReturn(adminFiles());
|
||||||
|
when(templateFileService.selectTemplateFilesByTemplateId(FRONTEND_TEMPLATE_ID)).thenReturn(frontendFiles());
|
||||||
|
setField(service, "templateFileService", templateFileService);
|
||||||
|
|
||||||
|
ISysProjectStructureService structureService = mock(ISysProjectStructureService.class);
|
||||||
|
when(structureService.selectSysProjectStructureList(any(SysProjectStructure.class))).thenAnswer(invocation -> {
|
||||||
|
Long templateId = ((SysProjectStructure) invocation.getArgument(0)).getTemplateId();
|
||||||
|
if (Long.valueOf(BACKEND_TEMPLATE_ID).equals(templateId)) return backendStructure();
|
||||||
|
if (Long.valueOf(ADMIN_TEMPLATE_ID).equals(templateId)) return frontendStructure(true);
|
||||||
|
return frontendStructure(false);
|
||||||
|
});
|
||||||
|
setField(service, "sysProjectStructureService", structureService);
|
||||||
|
|
||||||
|
ISysProjectModuleService moduleService = mock(ISysProjectModuleService.class);
|
||||||
|
when(moduleService.selectSysProjectModuleList(any(SysProjectModule.class)))
|
||||||
|
.thenReturn(Collections.<SysProjectModule>emptyList());
|
||||||
|
setField(service, "sysProjectModuleService", moduleService);
|
||||||
|
ISysCodeSnippetService snippetService = mock(ISysCodeSnippetService.class);
|
||||||
|
when(snippetService.selectSysCodeSnippetList(any(SysCodeSnippet.class)))
|
||||||
|
.thenReturn(Collections.<SysCodeSnippet>emptyList());
|
||||||
|
setField(service, "codeSnippetService", snippetService);
|
||||||
|
setField(service, "sysProjectTemplateService", mock(ISysProjectTemplateService.class));
|
||||||
|
|
||||||
|
BusinessBlockGenerationService blockService = new BusinessBlockGenerationService();
|
||||||
|
BusinessBlockRegistryService registryService = new BusinessBlockRegistryService();
|
||||||
|
registryService.setPageBlockPlugins(Collections.emptyList());
|
||||||
|
setField(blockService, "businessBlockRegistryService", registryService);
|
||||||
|
setField(service, "businessBlockGenerationService", blockService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void representativeProjectsGenerateDeterministicallyThreeTimes() throws Exception
|
||||||
|
{
|
||||||
|
List<Scenario> scenarios = Arrays.asList(
|
||||||
|
new Scenario("basic-crud", basicCrudProject()),
|
||||||
|
new Scenario("relation-dictionary", relationDictionaryProject()),
|
||||||
|
new Scenario("multi-page-modules", multiPageProject()));
|
||||||
|
String outputValue = System.getProperty("qing.matrix.output");
|
||||||
|
Path outputRoot = outputValue == null ? null : Paths.get(outputValue).toAbsolutePath().normalize();
|
||||||
|
if (outputRoot != null)
|
||||||
|
{
|
||||||
|
Path allowedRoot = moduleRoot().resolve("target").toAbsolutePath().normalize();
|
||||||
|
if (!outputRoot.startsWith(allowedRoot))
|
||||||
|
{
|
||||||
|
throw new IllegalArgumentException("qing.matrix.output must stay under " + allowedRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Scenario scenario : scenarios)
|
||||||
|
{
|
||||||
|
for (String type : TYPES)
|
||||||
|
{
|
||||||
|
String expectedFingerprint = null;
|
||||||
|
Map<String, byte[]> lastEntries = null;
|
||||||
|
for (int run = 1; run <= 3; run++)
|
||||||
|
{
|
||||||
|
Map<String, byte[]> entries = unzip(service.downloadStructure(scenario.project, type));
|
||||||
|
String fingerprint = fingerprint(entries);
|
||||||
|
if (expectedFingerprint == null) expectedFingerprint = fingerprint;
|
||||||
|
assertEquals(scenario.code + " / " + type + " / run " + run,
|
||||||
|
expectedFingerprint, fingerprint);
|
||||||
|
assertCompleteProject(scenario, type, entries);
|
||||||
|
lastEntries = entries;
|
||||||
|
}
|
||||||
|
if (outputRoot != null)
|
||||||
|
{
|
||||||
|
extract(lastEntries, outputRoot.resolve(scenario.code).resolve(type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertCompleteProject(Scenario scenario, String type, Map<String, byte[]> entries)
|
||||||
|
{
|
||||||
|
String root = scenario.project.getProjectFileName()
|
||||||
|
+ ("backend".equals(type) ? "-backend/" : "admin_frontend".equals(type) ? "-admin/" : "-web/");
|
||||||
|
assertFalse(scenario.code + " / " + type + " must not be empty", entries.isEmpty());
|
||||||
|
if ("backend".equals(type))
|
||||||
|
{
|
||||||
|
assertTrue(entries.containsKey(root + "pom.xml"));
|
||||||
|
assertTrue(entries.containsKey(root + "src/main/java/com/example/generated/GeneratedMatrixApplication.java"));
|
||||||
|
for (GenTable table : scenario.project.getTables())
|
||||||
|
{
|
||||||
|
assertTrue(entries.containsKey(root + "src/main/java/com/example/generated/entity/"
|
||||||
|
+ table.getClassName() + ".java"));
|
||||||
|
assertTrue(entries.containsKey(root + "src/main/java/com/example/generated/controller/"
|
||||||
|
+ table.getClassName() + "Controller.java"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertTrue(entries.containsKey(root + "package.json"));
|
||||||
|
assertTrue(entries.containsKey(root + "src/main.js"));
|
||||||
|
assertTrue(entries.containsKey(root + "src/router/index.js"));
|
||||||
|
assertTrue(new String(entries.get(root + "package.json"), StandardCharsets.UTF_8)
|
||||||
|
.contains("\"lint\": \"vue-cli-service lint --no-fix\""));
|
||||||
|
for (GenTable table : scenario.project.getTables())
|
||||||
|
{
|
||||||
|
assertTrue(entries.containsKey(root + "src/views/" + table.getModuleName() + "/"
|
||||||
|
+ table.getBusinessName() + "/index.vue"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenProject basicCrudProject()
|
||||||
|
{
|
||||||
|
GenTable item = table(101L, "inventory_item", "InventoryItem", "inventory", "item");
|
||||||
|
item.setColumns(Arrays.asList(
|
||||||
|
primaryKey(101L),
|
||||||
|
column(101L, "item_name", "itemName", "String", "varchar(100)", "商品名称", "input"),
|
||||||
|
column(101L, "price", "price", "BigDecimal", "decimal(10,2)", "价格", "input"),
|
||||||
|
column(101L, "stock", "stock", "Integer", "int(11)", "库存", "input"),
|
||||||
|
column(101L, "create_time", "createTime", "Date", "datetime", "创建时间", "datetime")));
|
||||||
|
return project("basic-crud", Collections.singletonList(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenProject relationDictionaryProject()
|
||||||
|
{
|
||||||
|
GenTable customer = table(101L, "customer", "Customer", "sales", "customer");
|
||||||
|
customer.setColumns(Arrays.asList(primaryKey(101L),
|
||||||
|
column(101L, "customer_name", "customerName", "String", "varchar(100)", "客户", "input")));
|
||||||
|
GenTable order = table(102L, "sales_order", "SalesOrder", "sales", "order");
|
||||||
|
GenTableColumn customerId = column(102L, "customer_id", "customerId", "Long", "bigint", "客户", "select");
|
||||||
|
customerId.setDictType("remote:customer:id:customer_name");
|
||||||
|
GenTableColumn status = column(102L, "status", "status", "String", "varchar(20)", "订单状态", "select");
|
||||||
|
status.setDictType("order_status");
|
||||||
|
status.setDictOptions("[{\"label\":\"待处理\",\"value\":\"PENDING\"},{\"label\":\"已完成\",\"value\":\"DONE\"}]");
|
||||||
|
order.setColumns(Arrays.asList(primaryKey(102L), customerId, status,
|
||||||
|
column(102L, "amount", "amount", "BigDecimal", "decimal(12,2)", "金额", "input")));
|
||||||
|
return project("relation-dictionary", Arrays.asList(customer, order));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenProject multiPageProject()
|
||||||
|
{
|
||||||
|
GenTable article = table(101L, "cms_article", "CmsArticle", "content", "article");
|
||||||
|
article.setColumns(Arrays.asList(primaryKey(101L),
|
||||||
|
column(101L, "title", "title", "String", "varchar(200)", "标题", "input"),
|
||||||
|
column(101L, "content", "content", "String", "text", "内容", "editor")));
|
||||||
|
GenTable category = table(102L, "cms_category", "CmsCategory", "content", "category");
|
||||||
|
category.setColumns(Arrays.asList(primaryKey(102L),
|
||||||
|
column(102L, "category_name", "categoryName", "String", "varchar(100)", "分类", "input")));
|
||||||
|
GenTable audit = table(103L, "audit_event", "AuditEvent", "audit", "event");
|
||||||
|
audit.setColumns(Arrays.asList(primaryKey(103L),
|
||||||
|
column(103L, "event_type", "eventType", "String", "varchar(50)", "事件类型", "select"),
|
||||||
|
column(103L, "event_time", "eventTime", "Date", "datetime", "事件时间", "datetime")));
|
||||||
|
GenProject project = project("multi-page-modules", Arrays.asList(article, category, audit));
|
||||||
|
project.setPageDesigns(Arrays.asList(
|
||||||
|
page("article_catalog", "frontend", "/articles", "cms_article", "list"),
|
||||||
|
page("article_editor", "admin", "/admin/articles/edit", "cms_article", "form"),
|
||||||
|
page("audit_dashboard", "admin", "/admin/audit", "audit_event", "list")));
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenProject project(String code, List<GenTable> tables)
|
||||||
|
{
|
||||||
|
GenProject project = new GenProject();
|
||||||
|
project.setProjectId(1L);
|
||||||
|
project.setProjectName("Generation Matrix " + code);
|
||||||
|
project.setProjectFileName("generated-matrix");
|
||||||
|
project.setPackageName("com.example.generated");
|
||||||
|
project.setProjectDesc(code);
|
||||||
|
project.setAuthor("matrix-test");
|
||||||
|
project.setCodeTemplate("qing");
|
||||||
|
project.setStylePreset("light");
|
||||||
|
project.setGenerationDate("2026-07-15");
|
||||||
|
project.setFrontDraft(true);
|
||||||
|
project.setTables(tables);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenTable table(Long id, String tableName, String className, String moduleName, String businessName)
|
||||||
|
{
|
||||||
|
GenTable table = new GenTable();
|
||||||
|
table.setTableId(id);
|
||||||
|
table.setTableName(tableName);
|
||||||
|
table.setTableComment(className);
|
||||||
|
table.setClassName(className);
|
||||||
|
table.setModuleName(moduleName);
|
||||||
|
table.setBusinessName(businessName);
|
||||||
|
table.setFunctionName(className);
|
||||||
|
table.setFunctionAuthor("matrix-test");
|
||||||
|
table.setTplCategory("crud");
|
||||||
|
table.setOptions("{}");
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenTableColumn primaryKey(Long tableId)
|
||||||
|
{
|
||||||
|
GenTableColumn column = column(tableId, "id", "id", "Long", "bigint", "ID", "input");
|
||||||
|
column.setIsPk("1");
|
||||||
|
column.setIsIncrement("1");
|
||||||
|
column.setIsInsert("0");
|
||||||
|
column.setIsEdit("0");
|
||||||
|
column.setIsQuery("0");
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenTableColumn column(Long tableId, String name, String field, String javaType,
|
||||||
|
String columnType, String label, String htmlType)
|
||||||
|
{
|
||||||
|
GenTableColumn column = new GenTableColumn();
|
||||||
|
column.setTableId(tableId);
|
||||||
|
column.setColumnName(name);
|
||||||
|
column.setColumnComment(label);
|
||||||
|
column.setColumnLabel(label);
|
||||||
|
column.setColumnType(columnType);
|
||||||
|
column.setJavaType(javaType);
|
||||||
|
column.setJavaField(field);
|
||||||
|
column.setIsPk("0");
|
||||||
|
column.setIsIncrement("0");
|
||||||
|
column.setIsRequired("1");
|
||||||
|
column.setIsInsert("1");
|
||||||
|
column.setIsEdit("1");
|
||||||
|
column.setIsList("1");
|
||||||
|
column.setIsQuery("1");
|
||||||
|
column.setQueryType("EQ");
|
||||||
|
column.setHtmlType(htmlType);
|
||||||
|
column.setSort(1);
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FrontProjectPageDesign page(String code, String scope, String route, String table, String type)
|
||||||
|
{
|
||||||
|
FrontProjectPageDesign page = new FrontProjectPageDesign();
|
||||||
|
page.setProjectId(1L);
|
||||||
|
page.setPageCode(code);
|
||||||
|
page.setPageName(code);
|
||||||
|
page.setPageScope(scope);
|
||||||
|
page.setRoutePath(route);
|
||||||
|
page.setTableName(table);
|
||||||
|
page.setPageType(type);
|
||||||
|
page.setLayoutJson("{\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"visible\":true}]}");
|
||||||
|
page.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");
|
||||||
|
page.setStatus("0");
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SysProjectStructure> backendStructure()
|
||||||
|
{
|
||||||
|
List<SysProjectStructure> nodes = new ArrayList<SysProjectStructure>();
|
||||||
|
long id = 1L;
|
||||||
|
nodes.add(node(id++, 0L, "{projectName}-backend", "folder", null, null));
|
||||||
|
long root = 1L;
|
||||||
|
nodes.add(node(id++, root, "pom.xml", "file", -1L, "pom.xml.vm"));
|
||||||
|
nodes.add(node(id++, root, "README.md", "file", -1L, "README.md.vm"));
|
||||||
|
long src = id; nodes.add(node(id++, root, "src", "folder", null, null));
|
||||||
|
long main = id; nodes.add(node(id++, src, "main", "folder", null, null));
|
||||||
|
long java = id; nodes.add(node(id++, main, "java", "folder", null, null));
|
||||||
|
long pkg = id; nodes.add(node(id++, java, "{packagePath}", "folder", null, null));
|
||||||
|
nodes.add(node(id++, pkg, "{projectClassName}Application.java", "file", -1L, "application.java.vm"));
|
||||||
|
long common = id; nodes.add(node(id++, pkg, "common", "folder", null, null));
|
||||||
|
nodes.add(node(id++, common, "Result.java", "file", -1L, "Result.java.vm"));
|
||||||
|
long config = id; nodes.add(node(id++, pkg, "config", "folder", null, null));
|
||||||
|
nodes.add(node(id++, config, "MybatisPlusConfig.java", "file", -1L, "mybatisPlusConfig.java.vm"));
|
||||||
|
nodes.add(node(id++, config, "CorsConfig.java", "file", -1L, "corsConfig.java.vm"));
|
||||||
|
long security = id; nodes.add(node(id++, pkg, "security", "folder", null, null));
|
||||||
|
nodes.add(node(id++, security, "AuthPrincipal.java", "file", -1L, "AuthPrincipal.java.vm"));
|
||||||
|
nodes.add(node(id++, security, "PortalAuthTokenStore.java", "file", -1L, "PortalAuthTokenStore.java.vm"));
|
||||||
|
nodes.add(node(id++, security, "PortalAuthInterceptor.java", "file", -1L, "PortalAuthInterceptor.java.vm"));
|
||||||
|
long entity = id; nodes.add(node(id++, pkg, "entity", "folder", null, null));
|
||||||
|
nodes.add(node(id++, entity, "{ClassName}.java", "file", 0L, "entity.java.vm"));
|
||||||
|
long mapper = id; nodes.add(node(id++, pkg, "mapper", "folder", null, null));
|
||||||
|
nodes.add(node(id++, mapper, "{ClassName}Mapper.java", "file", 0L, "mapper.java.vm"));
|
||||||
|
long serviceFolder = id; nodes.add(node(id++, pkg, "service", "folder", null, null));
|
||||||
|
nodes.add(node(id++, serviceFolder, "I{ClassName}Service.java", "file", 0L, "service.java.vm"));
|
||||||
|
long impl = id; nodes.add(node(id++, serviceFolder, "impl", "folder", null, null));
|
||||||
|
nodes.add(node(id++, impl, "{ClassName}ServiceImpl.java", "file", 0L, "serviceImpl.java.vm"));
|
||||||
|
long controller = id; nodes.add(node(id++, pkg, "controller", "folder", null, null));
|
||||||
|
nodes.add(node(id++, controller, "{ClassName}Controller.java", "file", 0L, "controller.java.vm"));
|
||||||
|
nodes.add(node(id++, controller, "PortalAuthController.java", "file", -1L, "PortalAuthController.java.vm"));
|
||||||
|
nodes.add(node(id++, controller, "AdminAuthController.java", "file", -1L, "AdminAuthController.java.vm"));
|
||||||
|
nodes.add(node(id++, controller, "FileController.java", "file", -1L, "FileController.java.vm"));
|
||||||
|
long resources = id; nodes.add(node(id++, main, "resources", "folder", null, null));
|
||||||
|
nodes.add(node(id++, resources, "application.yml", "file", -1L, "application.yml.vm"));
|
||||||
|
long mapperXml = id; nodes.add(node(id++, resources, "mapper", "folder", null, null));
|
||||||
|
nodes.add(node(id++, mapperXml, "{ClassName}Mapper.xml", "file", 0L, "mapper.xml.vm"));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SysProjectStructure> frontendStructure(boolean admin)
|
||||||
|
{
|
||||||
|
List<SysProjectStructure> nodes = new ArrayList<SysProjectStructure>();
|
||||||
|
long id = 101L;
|
||||||
|
String suffix = admin ? "-admin" : "-web";
|
||||||
|
nodes.add(node(id++, 0L, "{projectName}" + suffix, "folder", null, null));
|
||||||
|
long root = 101L;
|
||||||
|
nodes.add(node(id++, root, "package.json", "file", -1L, "package.json.vm"));
|
||||||
|
nodes.add(node(id++, root, "babel.config.js", "file", -1L, "babel.config.js.vm"));
|
||||||
|
nodes.add(node(id++, root, "vue.config.js", "file", -1L, "vue.config.js.vm"));
|
||||||
|
long publicFolder = id; nodes.add(node(id++, root, "public", "folder", null, null));
|
||||||
|
nodes.add(node(id++, publicFolder, "index.html", "file", -1L, "index.html.vm"));
|
||||||
|
long src = id; nodes.add(node(id++, root, "src", "folder", null, null));
|
||||||
|
nodes.add(node(id++, src, "main.js", "file", -1L, admin ? "admin-main.js.vm" : "frontend-main.js.vm"));
|
||||||
|
nodes.add(node(id++, src, "App.vue", "file", -1L, admin ? "admin-App.vue.vm" : "frontend-App.vue.vm"));
|
||||||
|
long router = id; nodes.add(node(id++, src, "router", "folder", null, null));
|
||||||
|
nodes.add(node(id++, router, "index.js", "file", 101L, admin ? "admin-router.js.vm" : "frontend-router.js.vm"));
|
||||||
|
long utils = id; nodes.add(node(id++, src, "utils", "folder", null, null));
|
||||||
|
nodes.add(node(id++, utils, "request.js", "file", -1L, "request.js.vm"));
|
||||||
|
nodes.add(node(id++, utils, "dict.js", "file", -1L, "dict.js.vm"));
|
||||||
|
long views = id; nodes.add(node(id++, src, "views", "folder", null, null));
|
||||||
|
long login = id; nodes.add(node(id++, views, "login", "folder", null, null));
|
||||||
|
nodes.add(node(id++, login, "index.vue", "file", -1L, admin ? "admin-login.vue.vm" : "frontend-login.vue.vm"));
|
||||||
|
long module = id; nodes.add(node(id++, views, "{moduleName}", "folder", 0L, null));
|
||||||
|
long business = id; nodes.add(node(id++, module, "{businessName}", "folder", 0L, null));
|
||||||
|
nodes.add(node(id++, business, "index.vue", "file", 0L,
|
||||||
|
admin ? "admin-index.vue.vm" : "frontend-index.vue.vm"));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateFile> backendFiles() throws IOException
|
||||||
|
{
|
||||||
|
return Arrays.asList(
|
||||||
|
file(BACKEND_TEMPLATE_ID, "pom.xml.vm", "qing/pom.xml.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "README.md.vm", "qing/readme.md.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "application.java.vm", "qing/application.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "application.yml.vm", "qing/application.yml.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "Result.java.vm", "qing/result.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "mybatisPlusConfig.java.vm", "qing/mybatisPlusConfig.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "corsConfig.java.vm", "qing/corsConfig.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "AuthPrincipal.java.vm", "qing/authPrincipal.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "PortalAuthTokenStore.java.vm", "qing/portalAuthTokenStore.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "PortalAuthInterceptor.java.vm", "qing/portalAuthInterceptor.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "entity.java.vm", "qing/entity.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "mapper.java.vm", "qing/mapper.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "service.java.vm", "qing/service.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "serviceImpl.java.vm", "qing/serviceImpl.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "controller.java.vm", "qing/controller.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "PortalAuthController.java.vm", "qing/portalAuthController.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "AdminAuthController.java.vm", "qing/adminAuthController.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "FileController.java.vm", "qing/fileController.java.vm"),
|
||||||
|
file(BACKEND_TEMPLATE_ID, "mapper.xml.vm", "qing/mapper.xml.vm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateFile> frontendFiles() throws IOException
|
||||||
|
{
|
||||||
|
return Arrays.asList(
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "package.json.vm", "qing/vue-package.json.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "babel.config.js.vm", "qing/vue-babel.config.js.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "vue.config.js.vm", "qing/vue-config.js.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "index.html.vm", "qing/vue-index.html.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "frontend-main.js.vm", "qing/vue-main.js.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "frontend-App.vue.vm", "qing/vue-app.vue.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "frontend-router.js.vm", "qing/vue-router.js.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "request.js.vm", "qing/vue-request.js.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "dict.js.vm", "qing/vue-dict.js.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "frontend-login.vue.vm", "qing/vue-login.vue.vm"),
|
||||||
|
file(FRONTEND_TEMPLATE_ID, "frontend-index.vue.vm", "qing/index.vue.vm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateFile> adminFiles() throws IOException
|
||||||
|
{
|
||||||
|
return Arrays.asList(
|
||||||
|
file(ADMIN_TEMPLATE_ID, "package.json.vm", "qing/vue-package.json.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "babel.config.js.vm", "qing/vue-babel.config.js.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "vue.config.js.vm", "qing/vue-config.js.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "index.html.vm", "qing/vue-index.html.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "admin-main.js.vm", "qing/vue-main.js.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "admin-App.vue.vm", "qing/admin-app.vue.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "admin-router.js.vm", "qing/vue-router.js.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "request.js.vm", "qing/vue-request.js.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "dict.js.vm", "qing/vue-dict.js.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "admin-login.vue.vm", "qing/admin-login.vue.vm"),
|
||||||
|
file(ADMIN_TEMPLATE_ID, "admin-index.vue.vm", "qing/admin-index.vue.vm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TemplateFile file(long templateId, String name, String resource) throws IOException
|
||||||
|
{
|
||||||
|
TemplateFile file = new TemplateFile();
|
||||||
|
file.setTemplateId(templateId);
|
||||||
|
file.setFileName(name);
|
||||||
|
file.setFileContent(readResource(resource));
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readResource(String name) throws IOException
|
||||||
|
{
|
||||||
|
InputStream input = QingGenerationMatrixTest.class.getClassLoader().getResourceAsStream(name);
|
||||||
|
if (input == null) throw new IOException("Missing test template: " + name);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
byte[] buffer = new byte[4096];
|
||||||
|
int length;
|
||||||
|
while ((length = input.read(buffer)) != -1) output.write(buffer, 0, length);
|
||||||
|
return new String(output.toByteArray(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
input.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysProjectStructure node(long id, long parentId, String name, String type,
|
||||||
|
Long tableId, String category)
|
||||||
|
{
|
||||||
|
SysProjectStructure node = new SysProjectStructure();
|
||||||
|
node.setNodeId(id);
|
||||||
|
node.setParentId(parentId);
|
||||||
|
node.setNodeName(name);
|
||||||
|
node.setNodeType(type);
|
||||||
|
node.setTableId(tableId);
|
||||||
|
node.setCategory(category);
|
||||||
|
node.setStatus("0");
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Template template(long id, String type)
|
||||||
|
{
|
||||||
|
Template template = new Template();
|
||||||
|
template.setTemplateId(id);
|
||||||
|
template.setTemplateType(type);
|
||||||
|
template.setTemplateStatus(0L);
|
||||||
|
template.setBundleCode("qing");
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TemplateBundle bundle()
|
||||||
|
{
|
||||||
|
TemplateBundle bundle = new TemplateBundle();
|
||||||
|
bundle.setBundleCode("qing");
|
||||||
|
bundle.setStatus("0");
|
||||||
|
bundle.setSupportBackend("1");
|
||||||
|
bundle.setSupportFrontend("1");
|
||||||
|
bundle.setSupportAdminFrontend("1");
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long templateId(String type)
|
||||||
|
{
|
||||||
|
if ("backend".equals(type)) return BACKEND_TEMPLATE_ID;
|
||||||
|
if ("admin_frontend".equals(type)) return ADMIN_TEMPLATE_ID;
|
||||||
|
return FRONTEND_TEMPLATE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, byte[]> unzip(byte[] archive) throws IOException
|
||||||
|
{
|
||||||
|
Map<String, byte[]> entries = new LinkedHashMap<String, byte[]>();
|
||||||
|
ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive));
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zip.getNextEntry()) != null)
|
||||||
|
{
|
||||||
|
if (!entry.isDirectory())
|
||||||
|
{
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
byte[] buffer = new byte[4096];
|
||||||
|
int length;
|
||||||
|
while ((length = zip.read(buffer)) != -1) output.write(buffer, 0, length);
|
||||||
|
assertFalse("Duplicate ZIP entry: " + entry.getName(), entries.containsKey(entry.getName()));
|
||||||
|
entries.put(entry.getName(), output.toByteArray());
|
||||||
|
}
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
zip.close();
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fingerprint(Map<String, byte[]> entries) throws Exception
|
||||||
|
{
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
List<String> paths = new ArrayList<String>(entries.keySet());
|
||||||
|
Collections.sort(paths);
|
||||||
|
for (String path : paths)
|
||||||
|
{
|
||||||
|
digest.update(path.getBytes(StandardCharsets.UTF_8));
|
||||||
|
digest.update((byte) 0);
|
||||||
|
digest.update(entries.get(path));
|
||||||
|
digest.update((byte) 0);
|
||||||
|
}
|
||||||
|
StringBuilder value = new StringBuilder();
|
||||||
|
for (byte b : digest.digest()) value.append(String.format("%02x", b & 0xff));
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void extract(Map<String, byte[]> entries, Path output) throws IOException
|
||||||
|
{
|
||||||
|
if (Files.exists(output))
|
||||||
|
{
|
||||||
|
List<Path> paths = new ArrayList<Path>();
|
||||||
|
try (Stream<Path> stream = Files.walk(output))
|
||||||
|
{
|
||||||
|
stream.forEach(paths::add);
|
||||||
|
}
|
||||||
|
paths.sort(Comparator.reverseOrder());
|
||||||
|
for (Path path : paths) Files.delete(path);
|
||||||
|
}
|
||||||
|
Files.createDirectories(output);
|
||||||
|
for (Map.Entry<String, byte[]> entry : entries.entrySet())
|
||||||
|
{
|
||||||
|
Path target = output.resolve(entry.getKey()).normalize();
|
||||||
|
if (!target.startsWith(output)) throw new IOException("Unsafe generated path: " + entry.getKey());
|
||||||
|
Files.createDirectories(target.getParent());
|
||||||
|
Files.write(target, entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setField(Object target, String name, Object value) throws Exception
|
||||||
|
{
|
||||||
|
Field field = target.getClass().getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path moduleRoot()
|
||||||
|
{
|
||||||
|
Path cwd = Paths.get("").toAbsolutePath();
|
||||||
|
return "ruoyi-generator".equals(cwd.getFileName().toString()) ? cwd : cwd.resolve("ruoyi-generator");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Scenario
|
||||||
|
{
|
||||||
|
private final String code;
|
||||||
|
private final GenProject project;
|
||||||
|
|
||||||
|
private Scenario(String code, GenProject project)
|
||||||
|
{
|
||||||
|
this.code = code;
|
||||||
|
this.project = project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.ruoyi.generator.service.front;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.Test;
|
||||||
|
import com.ruoyi.generator.domain.front.AiGenerationTask;
|
||||||
|
|
||||||
|
public class AiGenerationTaskLeaseMapperContractTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
public void taskAndStageTerminalWritesAreLeaseSafe() throws Exception
|
||||||
|
{
|
||||||
|
Path module = moduleRoot();
|
||||||
|
Path taskMapper = module.resolve("src/main/resources/mapper/front/AiGenerationTaskMapper.xml");
|
||||||
|
Configuration configuration = new Configuration();
|
||||||
|
configuration.getTypeAliasRegistry().registerAlias("AiGenerationTask", AiGenerationTask.class);
|
||||||
|
try (InputStream input = Files.newInputStream(taskMapper))
|
||||||
|
{
|
||||||
|
new XMLMapperBuilder(input, configuration, taskMapper.toString(),
|
||||||
|
configuration.getSqlFragments()).parse();
|
||||||
|
}
|
||||||
|
assertNotNull(configuration.getMappedStatement(
|
||||||
|
"com.ruoyi.generator.mapper.front.AiGenerationTaskMapper.finishClaimedTask"));
|
||||||
|
|
||||||
|
String taskXml = read(taskMapper);
|
||||||
|
String finish = statement(taskXml, "<update id=\"finishClaimedTask\"", "</update>");
|
||||||
|
assertTrue(finish.contains("and status = 'RUNNING'"));
|
||||||
|
assertTrue(finish.contains("and locked_by = #{lockedBy}"));
|
||||||
|
assertTrue(finish.contains("locked_by = null"));
|
||||||
|
|
||||||
|
String progress = statement(taskXml, "<update id=\"updateAiGenerationTask\"", "</update>");
|
||||||
|
assertTrue(progress.contains("status == 'RUNNING'"));
|
||||||
|
assertTrue(progress.contains("and locked_by = #{lockedBy}"));
|
||||||
|
|
||||||
|
String cancel = statement(taskXml, "<update id=\"cancelPendingTask\"", "</update>");
|
||||||
|
assertTrue(cancel.contains("and status in ('QUEUED', 'RETRY_WAITING')"));
|
||||||
|
assertTrue(cancel.contains("and user_id = #{userId}"));
|
||||||
|
assertTrue(cancel.contains("and project_id = #{projectId}"));
|
||||||
|
|
||||||
|
String retry = statement(taskXml, "<update id=\"retryFailedTask\"", "</update>");
|
||||||
|
assertTrue(retry.contains("and status in ('FAILED', 'RETRY_WAITING')"));
|
||||||
|
assertTrue(retry.contains("and user_id = #{task.userId}"));
|
||||||
|
assertTrue(retry.contains("and project_id = #{task.projectId}"));
|
||||||
|
|
||||||
|
String stageXml = read(module.resolve("src/main/resources/mapper/factory/AiTaskStageMapper.xml"));
|
||||||
|
assertTrue(statement(stageXml, "<update id=\"completeStageAttempt\"", "</update>")
|
||||||
|
.contains("and status = 'RUNNING'"));
|
||||||
|
assertTrue(statement(stageXml, "<update id=\"failStageAttempt\"", "</update>")
|
||||||
|
.contains("and status = 'RUNNING'"));
|
||||||
|
|
||||||
|
String quotaXml = read(module.resolve("src/main/resources/mapper/front/AiQuotaBucketMapper.xml"));
|
||||||
|
assertTrue(quotaXml.contains("<select id=\"selectQuotaBucketForUpdate\""));
|
||||||
|
assertTrue(quotaXml.contains("for update"));
|
||||||
|
assertTrue(quotaXml.contains("on duplicate key update bucket_id = last_insert_id(bucket_id)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path moduleRoot()
|
||||||
|
{
|
||||||
|
Path cwd = Paths.get("").toAbsolutePath();
|
||||||
|
return "ruoyi-generator".equals(cwd.getFileName().toString()) ? cwd : cwd.resolve("ruoyi-generator");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String read(Path path) throws Exception
|
||||||
|
{
|
||||||
|
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String statement(String xml, String startMarker, String endMarker)
|
||||||
|
{
|
||||||
|
int start = xml.indexOf(startMarker);
|
||||||
|
int end = xml.indexOf(endMarker, start);
|
||||||
|
assertTrue("Mapper statement should exist: " + startMarker, start >= 0 && end > start);
|
||||||
|
return xml.substring(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,9 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
setField("deepSeekProperties", deepSeekProperties);
|
setField("deepSeekProperties", deepSeekProperties);
|
||||||
when(aiInvocationPlanner.plan(any(String.class))).thenAnswer(invocation ->
|
when(aiInvocationPlanner.plan(any(String.class))).thenAnswer(invocation ->
|
||||||
invocationPlan(invocation.getArgument(0)));
|
invocationPlan(invocation.getArgument(0)));
|
||||||
|
when(frontProjectMapper.lockFrontProjectByUserAndId(anyLong(), anyLong())).thenAnswer(invocation ->
|
||||||
|
Long.valueOf(20L).equals(invocation.getArgument(1)) ? project20() : project());
|
||||||
|
when(aiGenerationTaskMapper.retryFailedTask(any(AiGenerationTask.class))).thenReturn(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -368,8 +371,10 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
|
|
||||||
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
|
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
|
||||||
request.setGenerateType("one_click_project");
|
request.setGenerateType("one_click_project");
|
||||||
request.setProjectName("客户关系管理系统");
|
request.setProjectName("客户端伪造名称");
|
||||||
request.setProjectDesc("");
|
request.setProjectDesc("客户端伪造描述");
|
||||||
|
request.setCodeTemplate("client-template");
|
||||||
|
request.setStylePreset("client-style");
|
||||||
request.setExtraRequirements("");
|
request.setExtraRequirements("");
|
||||||
|
|
||||||
AiGenerationTaskStatusResponse response = service.createTask(10L, 20L, request);
|
AiGenerationTaskStatusResponse response = service.createTask(10L, 20L, request);
|
||||||
@@ -383,6 +388,11 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
assertEquals(Long.valueOf(10L), task.getUserId());
|
assertEquals(Long.valueOf(10L), task.getUserId());
|
||||||
assertEquals(Long.valueOf(20L), task.getProjectId());
|
assertEquals(Long.valueOf(20L), task.getProjectId());
|
||||||
assertEquals("one_click_project", task.getGenerateType());
|
assertEquals("one_click_project", task.getGenerateType());
|
||||||
|
assertTrue(task.getRequestPayload().contains("客户关系管理系统"));
|
||||||
|
assertTrue(task.getRequestPayload().contains("管理客户资料"));
|
||||||
|
assertTrue(task.getRequestPayload().contains("qing"));
|
||||||
|
assertTrue(task.getRequestPayload().contains("dark-tech"));
|
||||||
|
assertFalse(task.getRequestPayload().contains("客户端伪造"));
|
||||||
assertNotNull(task.getStageManifestJson());
|
assertNotNull(task.getStageManifestJson());
|
||||||
assertEquals(64, task.getStageManifestHash().length());
|
assertEquals(64, task.getStageManifestHash().length());
|
||||||
assertTrue(task.getStageManifestJson().contains("front.flow_config"));
|
assertTrue(task.getStageManifestJson().contains("front.flow_config"));
|
||||||
@@ -418,6 +428,50 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
assertEquals("QUEUED", response.getStatus());
|
assertEquals("QUEUED", response.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void createOneClickTaskRejectsBlankPersistedTemplateBeforeQuotaReservation()
|
||||||
|
{
|
||||||
|
FrontProject persisted = project();
|
||||||
|
persisted.setCodeTemplate(" ");
|
||||||
|
when(frontProjectMapper.selectFrontProjectByUserAndId(7L, 10L)).thenReturn(persisted);
|
||||||
|
when(frontProjectMapper.lockFrontProjectByUserAndId(7L, 10L)).thenReturn(persisted);
|
||||||
|
AiGenerationTaskCreateRequest request = request("one_click_project");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.createTask(7L, 10L, request);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
assertTrue(error.getMessage().contains("代码模板不能为空"));
|
||||||
|
verify(aiQuotaService, never()).reserve(anyLong());
|
||||||
|
verify(aiGenerationTaskMapper, never()).insertAiGenerationTask(any(AiGenerationTask.class));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected blank persisted template to be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void createTaskRejectsOversizedSerializedPayloadBeforeQuotaReservation()
|
||||||
|
{
|
||||||
|
when(frontProjectMapper.selectFrontProjectByUserAndId(7L, 10L)).thenReturn(project());
|
||||||
|
AiGenerationTaskCreateRequest request = request("code_analysis");
|
||||||
|
request.setPreviousMarkdown(repeat('x', 1024 * 1024 + 1));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.createTask(7L, 10L, request);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
assertTrue(error.getMessage().contains("生成请求内容过大"));
|
||||||
|
verify(aiQuotaService, never()).reserve(anyLong());
|
||||||
|
verify(aiGenerationTaskMapper, never()).insertAiGenerationTask(any(AiGenerationTask.class));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected oversized task payload to be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void createTaskDefersDispatchUntilTransactionCommit()
|
public void createTaskDefersDispatchUntilTransactionCommit()
|
||||||
{
|
{
|
||||||
@@ -550,7 +604,7 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
AiGenerationTaskStatusResponse response = service.retryTask(7L, 10L, 99L);
|
AiGenerationTaskStatusResponse response = service.retryTask(7L, 10L, 99L);
|
||||||
|
|
||||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||||
verify(aiGenerationTaskMapper).updateAiGenerationTask(taskCaptor.capture());
|
verify(aiGenerationTaskMapper).retryFailedTask(taskCaptor.capture());
|
||||||
AiGenerationTask retriedTask = taskCaptor.getValue();
|
AiGenerationTask retriedTask = taskCaptor.getValue();
|
||||||
assertEquals("QUEUED", retriedTask.getStatus());
|
assertEquals("QUEUED", retriedTask.getStatus());
|
||||||
assertEquals(Integer.valueOf(0), retriedTask.getProgress());
|
assertEquals(Integer.valueOf(0), retriedTask.getProgress());
|
||||||
@@ -588,12 +642,69 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
{
|
{
|
||||||
assertTrue(error.getMessage().contains("已有同类型任务正在生成"));
|
assertTrue(error.getMessage().contains("已有同类型任务正在生成"));
|
||||||
verify(aiQuotaService, never()).reserve(anyLong());
|
verify(aiQuotaService, never()).reserve(anyLong());
|
||||||
verify(aiGenerationTaskMapper, never()).updateAiGenerationTask(any(AiGenerationTask.class));
|
verify(aiGenerationTaskMapper, never()).retryFailedTask(any(AiGenerationTask.class));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new AssertionError("Expected retry conflict to be rejected");
|
throw new AssertionError("Expected retry conflict to be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retryTaskRechecksStatusAfterProjectLockBeforeReservingQuota()
|
||||||
|
{
|
||||||
|
AiGenerationTask initiallyFailed = new AiGenerationTask();
|
||||||
|
initiallyFailed.setTaskId(99L);
|
||||||
|
initiallyFailed.setProjectId(10L);
|
||||||
|
initiallyFailed.setUserId(7L);
|
||||||
|
initiallyFailed.setGenerateType("one_click_project");
|
||||||
|
initiallyFailed.setStatus("FAILED");
|
||||||
|
AiGenerationTask concurrentlyQueued = new AiGenerationTask();
|
||||||
|
concurrentlyQueued.setTaskId(99L);
|
||||||
|
concurrentlyQueued.setProjectId(10L);
|
||||||
|
concurrentlyQueued.setUserId(7L);
|
||||||
|
concurrentlyQueued.setGenerateType("one_click_project");
|
||||||
|
concurrentlyQueued.setStatus("QUEUED");
|
||||||
|
when(aiGenerationTaskMapper.selectTaskForUser(7L, 10L, 99L))
|
||||||
|
.thenReturn(initiallyFailed, concurrentlyQueued);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.retryTask(7L, 10L, 99L);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
assertTrue(error.getMessage().contains("当前任务状态不允许重试"));
|
||||||
|
verify(aiQuotaService, never()).reserve(anyLong());
|
||||||
|
verify(aiGenerationTaskMapper, never()).retryFailedTask(any(AiGenerationTask.class));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected concurrent retry to be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retryTaskRejectsWorkerClaimThatWinsAtomicTransition()
|
||||||
|
{
|
||||||
|
AiGenerationTask task = new AiGenerationTask();
|
||||||
|
task.setTaskId(99L);
|
||||||
|
task.setProjectId(10L);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setGenerateType("database");
|
||||||
|
task.setStatus("RETRY_WAITING");
|
||||||
|
when(aiGenerationTaskMapper.selectTaskForUser(7L, 10L, 99L)).thenReturn(task);
|
||||||
|
when(aiGenerationTaskMapper.retryFailedTask(any(AiGenerationTask.class))).thenReturn(0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.retryTask(7L, 10L, 99L);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
assertTrue(error.getMessage().contains("当前任务状态不允许重试"));
|
||||||
|
verify(aiQuotaService).reserve(7L);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected worker claim race to be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void retryTaskClearsStaleWorkerLockBeforeQueueing()
|
public void retryTaskClearsStaleWorkerLockBeforeQueueing()
|
||||||
{
|
{
|
||||||
@@ -614,7 +725,7 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
service.retryTask(7L, 10L, 99L);
|
service.retryTask(7L, 10L, 99L);
|
||||||
|
|
||||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||||
verify(aiGenerationTaskMapper).updateAiGenerationTask(taskCaptor.capture());
|
verify(aiGenerationTaskMapper).retryFailedTask(taskCaptor.capture());
|
||||||
AiGenerationTask retriedTask = taskCaptor.getValue();
|
AiGenerationTask retriedTask = taskCaptor.getValue();
|
||||||
assertEquals("QUEUED", retriedTask.getStatus());
|
assertEquals("QUEUED", retriedTask.getStatus());
|
||||||
assertNull(retriedTask.getLockedBy());
|
assertNull(retriedTask.getLockedBy());
|
||||||
@@ -639,13 +750,59 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
AiGenerationTaskStatusResponse response = service.retryTask(7L, 10L, 99L);
|
AiGenerationTaskStatusResponse response = service.retryTask(7L, 10L, 99L);
|
||||||
|
|
||||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||||
verify(aiGenerationTaskMapper).updateAiGenerationTask(taskCaptor.capture());
|
verify(aiGenerationTaskMapper).retryFailedTask(taskCaptor.capture());
|
||||||
AiGenerationTask retriedTask = taskCaptor.getValue();
|
AiGenerationTask retriedTask = taskCaptor.getValue();
|
||||||
assertEquals("QUEUED", retriedTask.getStatus());
|
assertEquals("QUEUED", retriedTask.getStatus());
|
||||||
assertEquals("", retriedTask.getResultPayload());
|
assertEquals("", retriedTask.getResultPayload());
|
||||||
assertEquals("", response.getResultPayload());
|
assertEquals("", response.getResultPayload());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void cancelTaskUsesAtomicPendingTransitionBeforeReleasingQuota()
|
||||||
|
{
|
||||||
|
AiGenerationTask task = new AiGenerationTask();
|
||||||
|
task.setTaskId(99L);
|
||||||
|
task.setProjectId(10L);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setGenerateType("database");
|
||||||
|
task.setStatus("QUEUED");
|
||||||
|
task.setLockedBy("stale-worker");
|
||||||
|
when(aiGenerationTaskMapper.selectTaskForUser(7L, 10L, 99L)).thenReturn(task);
|
||||||
|
when(aiGenerationTaskMapper.cancelPendingTask(7L, 10L, 99L)).thenReturn(1);
|
||||||
|
|
||||||
|
AiGenerationTaskStatusResponse response = service.cancelTask(7L, 10L, 99L);
|
||||||
|
|
||||||
|
assertEquals("CANCELED", response.getStatus());
|
||||||
|
assertNull(task.getLockedBy());
|
||||||
|
verify(aiQuotaService).releaseRunning(7L);
|
||||||
|
verify(aiGenerationTaskMapper, never()).updateAiGenerationTask(any(AiGenerationTask.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void cancelTaskDoesNotReleaseQuotaWhenConcurrentTransitionWins()
|
||||||
|
{
|
||||||
|
AiGenerationTask task = new AiGenerationTask();
|
||||||
|
task.setTaskId(99L);
|
||||||
|
task.setProjectId(10L);
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setGenerateType("database");
|
||||||
|
task.setStatus("QUEUED");
|
||||||
|
when(aiGenerationTaskMapper.selectTaskForUser(7L, 10L, 99L)).thenReturn(task);
|
||||||
|
when(aiGenerationTaskMapper.cancelPendingTask(7L, 10L, 99L)).thenReturn(0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.cancelTask(7L, 10L, 99L);
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
assertTrue(error.getMessage().contains("当前任务状态不允许取消"));
|
||||||
|
verify(aiQuotaService, never()).releaseRunning(anyLong());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected concurrent cancellation conflict");
|
||||||
|
}
|
||||||
|
|
||||||
private AiGenerationTaskCreateRequest request(String generateType)
|
private AiGenerationTaskCreateRequest request(String generateType)
|
||||||
{
|
{
|
||||||
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
|
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
|
||||||
@@ -658,12 +815,22 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String repeat(char value, int count)
|
||||||
|
{
|
||||||
|
char[] result = new char[count];
|
||||||
|
Arrays.fill(result, value);
|
||||||
|
return new String(result);
|
||||||
|
}
|
||||||
|
|
||||||
private FrontProject project()
|
private FrontProject project()
|
||||||
{
|
{
|
||||||
FrontProject project = new FrontProject();
|
FrontProject project = new FrontProject();
|
||||||
project.setProjectId(10L);
|
project.setProjectId(10L);
|
||||||
project.setUserId(7L);
|
project.setUserId(7L);
|
||||||
project.setProjectName("客户中心");
|
project.setProjectName("客户中心");
|
||||||
|
project.setProjectDesc("管理客户资料");
|
||||||
|
project.setCodeTemplate("qing");
|
||||||
|
project.setStylePreset("dark-tech");
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +860,9 @@ public class AiGenerationTaskServiceImplTest
|
|||||||
project.setProjectId(20L);
|
project.setProjectId(20L);
|
||||||
project.setUserId(10L);
|
project.setUserId(10L);
|
||||||
project.setProjectName("客户关系管理系统");
|
project.setProjectName("客户关系管理系统");
|
||||||
|
project.setProjectDesc("管理客户资料");
|
||||||
|
project.setCodeTemplate("qing");
|
||||||
|
project.setStylePreset("dark-tech");
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package com.ruoyi.generator.service.front;
|
|||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.atLeast;
|
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.inOrder;
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ public class AiGenerationTaskWorkerTest
|
|||||||
setField("aiQuotaService", aiQuotaService);
|
setField("aiQuotaService", aiQuotaService);
|
||||||
setField("retryPolicy", retryPolicy);
|
setField("retryPolicy", retryPolicy);
|
||||||
setField("costCalculator", costCalculator);
|
setField("costCalculator", costCalculator);
|
||||||
|
when(taskMapper.updateAiGenerationTask(any(AiGenerationTask.class))).thenReturn(1);
|
||||||
|
when(taskMapper.finishClaimedTask(any(AiGenerationTask.class), anyString())).thenReturn(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -66,13 +69,57 @@ public class AiGenerationTaskWorkerTest
|
|||||||
worker.processTask(99L);
|
worker.processTask(99L);
|
||||||
|
|
||||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||||
verify(taskMapper, atLeast(2)).updateAiGenerationTask(taskCaptor.capture());
|
verify(taskMapper).finishClaimedTask(taskCaptor.capture(), anyString());
|
||||||
AiGenerationTask failed = taskCaptor.getAllValues().get(taskCaptor.getAllValues().size() - 1);
|
AiGenerationTask failed = taskCaptor.getValue();
|
||||||
assertEquals("FAILED", failed.getStatus());
|
assertEquals("FAILED", failed.getStatus());
|
||||||
assertEquals("Generation failed", failed.getCurrentStep());
|
assertEquals("Generation failed", failed.getCurrentStep());
|
||||||
assertTrue(failed.getErrorMessage().contains("AssertionError"));
|
assertTrue(failed.getErrorMessage().contains("AssertionError"));
|
||||||
verify(taskMapper).renewTaskLock(eq(99L), anyString());
|
verify(taskMapper).renewTaskLock(eq(99L), anyString());
|
||||||
verify(taskMapper).clearTaskLock(99L);
|
verify(aiQuotaService).releaseRunning(7L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void staleWorkerCannotFinalizeOrReleaseQuotaAfterLeaseIsLost()
|
||||||
|
{
|
||||||
|
AiGenerationTask task = task();
|
||||||
|
when(taskMapper.selectAiGenerationTaskById(99L)).thenReturn(task);
|
||||||
|
when(taskMapper.claimTask(eq(99L), anyString())).thenReturn(1);
|
||||||
|
when(taskMapper.finishClaimedTask(any(AiGenerationTask.class), anyString())).thenReturn(0);
|
||||||
|
|
||||||
|
worker.processTask(99L);
|
||||||
|
|
||||||
|
verify(taskMapper).finishClaimedTask(any(AiGenerationTask.class), anyString());
|
||||||
|
verify(aiQuotaService, never()).settleCost(any(Long.class), anyInt(), anyInt());
|
||||||
|
verify(aiQuotaService, never()).releaseRunning(7L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void postFinalizationFailureStillReleasesRunningQuota()
|
||||||
|
{
|
||||||
|
AiGenerationTask task = task();
|
||||||
|
when(taskMapper.selectAiGenerationTaskById(99L)).thenReturn(task);
|
||||||
|
when(taskMapper.claimTask(eq(99L), anyString())).thenReturn(1);
|
||||||
|
doThrow(new AssertionError("settlement failed")).when(aiQuotaService)
|
||||||
|
.settleCost(eq(7L), anyInt(), anyInt());
|
||||||
|
|
||||||
|
worker.processTask(99L);
|
||||||
|
|
||||||
|
verify(taskMapper).finishClaimedTask(any(AiGenerationTask.class), anyString());
|
||||||
|
verify(aiQuotaService).releaseRunning(7L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void postFinalizationRuntimeFailureDoesNotRefinalizeTask()
|
||||||
|
{
|
||||||
|
AiGenerationTask task = task();
|
||||||
|
when(taskMapper.selectAiGenerationTaskById(99L)).thenReturn(task);
|
||||||
|
when(taskMapper.claimTask(eq(99L), anyString())).thenReturn(1);
|
||||||
|
doThrow(new IllegalStateException("settlement failed")).when(aiQuotaService)
|
||||||
|
.settleCost(eq(7L), anyInt(), anyInt());
|
||||||
|
|
||||||
|
worker.processTask(99L);
|
||||||
|
|
||||||
|
verify(taskMapper).finishClaimedTask(any(AiGenerationTask.class), anyString());
|
||||||
verify(aiQuotaService).releaseRunning(7L);
|
verify(aiQuotaService).releaseRunning(7L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ public class AiQuotaServiceTest
|
|||||||
AiQuotaBucket day = bucket("DAY", "20260703");
|
AiQuotaBucket day = bucket("DAY", "20260703");
|
||||||
day.setRunningCount(1);
|
day.setRunningCount(1);
|
||||||
AiQuotaBucket month = bucket("MONTH", "202607");
|
AiQuotaBucket month = bucket("MONTH", "202607");
|
||||||
when(aiQuotaBucketMapper.selectQuotaBucket(org.mockito.ArgumentMatchers.eq(7L),
|
when(aiQuotaBucketMapper.selectQuotaBucketForUpdate(org.mockito.ArgumentMatchers.eq(7L),
|
||||||
org.mockito.ArgumentMatchers.eq("DAY"), org.mockito.ArgumentMatchers.anyString())).thenReturn(day);
|
org.mockito.ArgumentMatchers.eq("DAY"), org.mockito.ArgumentMatchers.anyString())).thenReturn(day);
|
||||||
when(aiQuotaBucketMapper.selectQuotaBucket(org.mockito.ArgumentMatchers.eq(7L),
|
when(aiQuotaBucketMapper.selectQuotaBucketForUpdate(org.mockito.ArgumentMatchers.eq(7L),
|
||||||
org.mockito.ArgumentMatchers.eq("MONTH"), org.mockito.ArgumentMatchers.anyString())).thenReturn(month);
|
org.mockito.ArgumentMatchers.eq("MONTH"), org.mockito.ArgumentMatchers.anyString())).thenReturn(month);
|
||||||
when(aiGenerationTaskMapper.countActiveTasksForUser(7L)).thenReturn(0);
|
when(aiGenerationTaskMapper.countActiveTasksForUser(7L)).thenReturn(0);
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ public class AiQuotaServiceTest
|
|||||||
InOrder inOrder = inOrder(aiGenerationTaskMapper, aiQuotaBucketMapper);
|
InOrder inOrder = inOrder(aiGenerationTaskMapper, aiQuotaBucketMapper);
|
||||||
inOrder.verify(aiGenerationTaskMapper).releaseExpiredRunningTasks();
|
inOrder.verify(aiGenerationTaskMapper).releaseExpiredRunningTasks();
|
||||||
inOrder.verify(aiGenerationTaskMapper).countActiveTasksForUser(7L);
|
inOrder.verify(aiGenerationTaskMapper).countActiveTasksForUser(7L);
|
||||||
|
verify(aiQuotaBucketMapper).selectQuotaBucketForUpdate(org.mockito.ArgumentMatchers.eq(7L),
|
||||||
|
org.mockito.ArgumentMatchers.eq("DAY"), org.mockito.ArgumentMatchers.anyString());
|
||||||
verify(aiQuotaBucketMapper).updateQuotaBucket(day);
|
verify(aiQuotaBucketMapper).updateQuotaBucket(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,27 @@ public class FrontProjectPreviewServiceImplTest
|
|||||||
verify(genProjectService, never()).downloadStructure(any(GenProject.class), eq("sql"));
|
verify(genProjectService, never()).downloadStructure(any(GenProject.class), eq("sql"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void downloadAllRejectsUnsafeEntriesFromGeneratedArchives() throws Exception
|
||||||
|
{
|
||||||
|
FrontProject project = frontProjectWithOneTable();
|
||||||
|
when(frontProjectService.getProject(100L, 200L)).thenReturn(project);
|
||||||
|
when(genProjectService.downloadStructure(any(GenProject.class), eq("backend")))
|
||||||
|
.thenReturn(zipWithEntry("../outside.txt", "unsafe"));
|
||||||
|
|
||||||
|
ServiceException error = expectServiceException(new ThrowingRunnable()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void run()
|
||||||
|
{
|
||||||
|
previewService.downloadAll(100L, 200L);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(error.getMessage().contains("invalid path"));
|
||||||
|
verify(genProjectService, never()).downloadStructure(any(GenProject.class), eq("frontend"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void rejectsFrontendStructureWhenProjectDisablesFrontend()
|
public void rejectsFrontendStructureWhenProjectDisablesFrontend()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -238,6 +238,38 @@ public class FrontProjectServiceImplTest
|
|||||||
verify(frontProjectTableMapper, never()).deleteTablesByProjectId(any(Long.class));
|
verify(frontProjectTableMapper, never()).deleteTablesByProjectId(any(Long.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void saveDatabaseRejectsGeneratorPathTraversalMetadata()
|
||||||
|
{
|
||||||
|
when(frontProjectMapper.selectFrontProjectByUserAndId(7L, 10L)).thenReturn(project());
|
||||||
|
final DatabaseTableDesign unsafeModule = table("sys_user", pkColumn());
|
||||||
|
unsafeModule.setModuleName("../outside");
|
||||||
|
|
||||||
|
ServiceException moduleError = expectServiceException(new ThrowingRunnable()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void run()
|
||||||
|
{
|
||||||
|
service.saveDatabase(7L, 10L, database(unsafeModule));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertEquals("模块名只能包含字母、数字和下划线,且必须以字母开头", moduleError.getMessage());
|
||||||
|
|
||||||
|
final DatabaseTableDesign unsafeBusiness = table("sys_user", pkColumn());
|
||||||
|
unsafeBusiness.setBusinessName("C:/outside");
|
||||||
|
ServiceException businessError = expectServiceException(new ThrowingRunnable()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void run()
|
||||||
|
{
|
||||||
|
service.saveDatabase(7L, 10L, database(unsafeBusiness));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertEquals("业务名只能包含字母、数字和下划线,且必须以字母开头", businessError.getMessage());
|
||||||
|
verify(frontProjectColumnMapper, never()).deleteColumnsByProjectId(any(Long.class));
|
||||||
|
verify(frontProjectTableMapper, never()).deleteTablesByProjectId(any(Long.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void saveDatabaseAddsDefaultAutoIncrementPrimaryKeyWhenMissing()
|
public void saveDatabaseAddsDefaultAutoIncrementPrimaryKeyWhenMissing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,12 +68,15 @@ public class QingTemplateSupportTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void qingPackageIncludesEcharts()
|
public void qingPackageIncludesRuntimeAndLintTooling()
|
||||||
{
|
{
|
||||||
VelocityInitializer.initVelocity();
|
VelocityInitializer.initVelocity();
|
||||||
String content = render("qing/vue-package.json.vm", VelocityUtils.prepareContextProject(project()));
|
String content = render("qing/vue-package.json.vm", VelocityUtils.prepareContextProject(project()));
|
||||||
|
|
||||||
assertTrue(content.contains("\"echarts\": \"5.4.0\""));
|
assertTrue(content.contains("\"echarts\": \"5.4.0\""));
|
||||||
|
assertTrue(content.contains("\"lint\": \"vue-cli-service lint --no-fix\""));
|
||||||
|
assertTrue(content.contains("\"@vue/cli-plugin-eslint\": \"^4.5.19\""));
|
||||||
|
assertTrue(content.contains("\"eslint-plugin-vue\": \"^6.2.2\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.ruoyi.generator.util;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import org.junit.Test;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
|
||||||
|
public class ZipEntryPathValidatorTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
public void acceptsPortableRelativeProjectPaths()
|
||||||
|
{
|
||||||
|
assertEquals("demo/src/main/App.java",
|
||||||
|
ZipEntryPathValidator.requireRelative("demo\\src/main/App.java", "test"));
|
||||||
|
assertEquals("demo/src/", ZipEntryPathValidator.requireRelative("demo/src/", "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void rejectsTraversalAbsoluteDriveAndNonPortablePaths()
|
||||||
|
{
|
||||||
|
assertInvalid("../outside.txt");
|
||||||
|
assertInvalid("demo/../outside.txt");
|
||||||
|
assertInvalid("/absolute/path.txt");
|
||||||
|
assertInvalid("C:\\outside.txt");
|
||||||
|
assertInvalid("demo//file.txt");
|
||||||
|
assertInvalid("demo/file?.txt");
|
||||||
|
assertInvalid("demo/CON.txt");
|
||||||
|
assertInvalid("demo/name. ");
|
||||||
|
assertInvalid("demo/name.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertInvalid(String value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ZipEntryPathValidator.requireRelative(value, "test archive");
|
||||||
|
}
|
||||||
|
catch (ServiceException error)
|
||||||
|
{
|
||||||
|
assertTrue(error.getMessage().contains("invalid path"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected invalid path: " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user