完善一键生成项目的稳定性与验证
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
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.stereotype.Component;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
@@ -78,11 +82,28 @@ public class GenerateStageHandler implements OneClickGenerationStageHandler
|
||||
}
|
||||
result.setGenerationRunId(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);
|
||||
checkpointService.save(context.getTask(), stageCode(), result,
|
||||
result.getSpecVersionId(), result.getGenerationRunId());
|
||||
|
||||
@@ -47,8 +47,9 @@ public class RunPreviewStageHandler implements OneClickGenerationStageHandler
|
||||
context.getResult().getSpecVersionId(), context.getResult().getGenerationRunId());
|
||||
if (stageCode().equals(context.getResult().getFailedStage()))
|
||||
{
|
||||
stagePipeline.fail(context.getTask(), stageCode(),
|
||||
new ServiceException(context.getResult().getErrorMessage()));
|
||||
ServiceException failure = 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 int insertAiGenerationTask(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 renewTaskLock(@Param("taskId") Long taskId, @Param("lockedBy") String lockedBy);
|
||||
public int clearTaskLock(@Param("taskId") Long taskId);
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.ruoyi.generator.domain.front.AiQuotaBucket;
|
||||
public interface AiQuotaBucketMapper
|
||||
{
|
||||
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 updateQuotaBucket(AiQuotaBucket aiQuotaBucket);
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ public interface FrontProjectMapper
|
||||
public int updateFrontProjectIfRevision(FrontProject frontProject);
|
||||
public int deleteFrontProjectById(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);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.ruoyi.generator.util.TableOperationResolver;
|
||||
import com.ruoyi.generator.util.TypedBusinessActionCompiler;
|
||||
import com.ruoyi.generator.util.VelocityInitializer;
|
||||
import com.ruoyi.generator.util.VelocityUtils;
|
||||
import com.ruoyi.generator.util.ZipEntryPathValidator;
|
||||
import org.apache.velocity.VelocityContext;
|
||||
import org.apache.velocity.app.Velocity;
|
||||
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 BUNDLED_ADMIN_INDEX_TEMPLATE = "qing/admin-index.vue.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 FRONTEND_MAIN_TEMPLATE_FILE = "frontend-main.js.vm";
|
||||
private static final String RESOURCE_URL_PROTOCOL_CHECK =
|
||||
@@ -262,7 +264,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
||||
|
||||
Map<String, String> metadata = parseArtifactMetadata(artifact.getVariableMetadata());
|
||||
String root = generatedRootName(project, type);
|
||||
String rootEntry = root + "/";
|
||||
String rootEntry = ZipEntryPathValidator.requireRelative(root + "/", "Generated project");
|
||||
if (zipEntries.add(rootEntry)) {
|
||||
zip.putNextEntry(new ZipEntry(rootEntry));
|
||||
zip.closeEntry();
|
||||
@@ -276,7 +278,8 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
||||
input.closeEntry();
|
||||
continue;
|
||||
}
|
||||
String relativePath = replaceArtifactPathVariables(sourcePath, metadata, project, type);
|
||||
String relativePath = ZipEntryPathValidator.requireRelative(
|
||||
replaceArtifactPathVariables(sourcePath, metadata, project, type), "Template skeleton");
|
||||
String outputPath = root + "/" + relativePath;
|
||||
if (entry.isDirectory()) {
|
||||
String folderPath = outputPath.endsWith("/") ? outputPath : outputPath + "/";
|
||||
@@ -303,14 +306,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
||||
}
|
||||
|
||||
private String normalizeSkeletonEntry(String entryName) {
|
||||
String normalized = StringUtils.defaultString(entryName).replace('\\', '/');
|
||||
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;
|
||||
return ZipEntryPathValidator.requireRelative(entryName, "Template skeleton");
|
||||
}
|
||||
|
||||
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,
|
||||
String currentPath, String type, Set<String> zipEntries) throws IOException {
|
||||
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 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");
|
||||
Object tableId = node.get("tableId");
|
||||
String fullPath = currentPath + name;
|
||||
String fullPath = ZipEntryPathValidator.requireRelative(currentPath + name,
|
||||
"Generated project structure");
|
||||
|
||||
if ("folder".equals(nodeType)) {
|
||||
String folderPath = fullPath + "/";
|
||||
@@ -422,6 +425,7 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
||||
if (outputPath == null) {
|
||||
continue;
|
||||
}
|
||||
outputPath = ZipEntryPathValidator.requireRelative(outputPath, "Generated project structure");
|
||||
String content = generateFileContent(project, category, (Long) tableId, type);
|
||||
if (content != null && zipEntries.add(outputPath)) {
|
||||
zip.putNextEntry(new ZipEntry(outputPath));
|
||||
@@ -1079,6 +1083,12 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
||||
private String resolveTemplateContent(TemplateFile templateFile) {
|
||||
String content = upgradeRichTextEditorSupport(templateFile,
|
||||
StringUtils.defaultString(templateFile.getFileContent()));
|
||||
if (shouldUseBundledFrontendPackageTemplate(templateFile)) {
|
||||
String bundledContent = readClasspathTemplate(BUNDLED_FRONTEND_PACKAGE_TEMPLATE);
|
||||
if (StringUtils.isNotEmpty(bundledContent)) {
|
||||
return bundledContent;
|
||||
}
|
||||
}
|
||||
if (shouldUseBundledBackendControllerTemplate(templateFile, content)) {
|
||||
String bundledContent = readClasspathTemplate(BUNDLED_BACKEND_CONTROLLER_TEMPLATE);
|
||||
if (StringUtils.isNotEmpty(bundledContent)) {
|
||||
@@ -1148,6 +1158,13 @@ public class GenProjectServiceImpl implements IGenProjectService {
|
||||
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) {
|
||||
if (templateFile == null || (!RUNNABLE_ADMIN_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 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 =
|
||||
new OneClickTaskProgressComposer();
|
||||
|
||||
@@ -73,10 +77,16 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
||||
@Transactional
|
||||
public AiGenerationTaskStatusResponse createTask(Long userId, Long projectId, AiGenerationTaskCreateRequest request)
|
||||
{
|
||||
FrontProject project = assertOwnedProject(userId, projectId);
|
||||
validateRequest(request);
|
||||
validateGenerateType(request);
|
||||
FrontProject project = lockOwnedProject(userId, projectId);
|
||||
normalizeOneClickRequest(project, request);
|
||||
validateRequestContent(request);
|
||||
releaseExpiredRunningTasks();
|
||||
String requestPayload = JSON.toJSONString(request);
|
||||
if (requestPayload.getBytes(StandardCharsets.UTF_8).length > MAX_TASK_REQUEST_BYTES)
|
||||
{
|
||||
throw new ServiceException("生成请求内容过大");
|
||||
}
|
||||
AiInvocationPlan invocation = invocationPlan(request.getGenerateType());
|
||||
AiStageManifest stageManifest = stageManifest(request.getGenerateType());
|
||||
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)
|
||||
{
|
||||
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()))
|
||||
{
|
||||
throw new ServiceException("当前任务状态不允许重试");
|
||||
@@ -158,8 +175,12 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
||||
task.setErrorMessage("");
|
||||
task.setNextRetryTime(new Date());
|
||||
clearTaskLock(task);
|
||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
||||
aiGenerationTaskMapper.clearTaskLock(taskId);
|
||||
task.setStartedAt(null);
|
||||
task.setFinishedAt(null);
|
||||
if (aiGenerationTaskMapper.retryFailedTask(task) == 0)
|
||||
{
|
||||
throw new ServiceException("当前任务状态不允许重试");
|
||||
}
|
||||
dispatchAfterCommit(taskId);
|
||||
return toResponse(task);
|
||||
}
|
||||
@@ -173,11 +194,15 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
||||
{
|
||||
throw new ServiceException("当前任务状态不允许取消");
|
||||
}
|
||||
if (aiGenerationTaskMapper.cancelPendingTask(userId, projectId, taskId) == 0)
|
||||
{
|
||||
throw new ServiceException("当前任务状态不允许取消");
|
||||
}
|
||||
task.setStatus("CANCELED");
|
||||
task.setProgress(0);
|
||||
task.setCurrentStep("已取消");
|
||||
task.setFinishedAt(new Date());
|
||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
||||
clearTaskLock(task);
|
||||
aiQuotaService.releaseRunning(userId);
|
||||
return toResponse(task);
|
||||
}
|
||||
@@ -279,7 +304,7 @@ public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
|
||||
return task;
|
||||
}
|
||||
|
||||
private void validateRequest(AiGenerationTaskCreateRequest request)
|
||||
private void validateGenerateType(AiGenerationTaskCreateRequest request)
|
||||
{
|
||||
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,
|
||||
String requestPayload, AiInvocationPlan invocation)
|
||||
{
|
||||
|
||||
@@ -106,11 +106,16 @@ public class AiGenerationTaskWorker
|
||||
task.setProgress(initialProgress(task.getGenerateType()));
|
||||
task.setCurrentStep(initialCurrentStep(task.getGenerateType()));
|
||||
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);
|
||||
|
||||
AiUsageScope usageScope = taskUsageScope(task);
|
||||
ScheduledFuture<?> heartbeat = startHeartbeat(task);
|
||||
boolean finalized = false;
|
||||
try
|
||||
{
|
||||
Object result = executeWithUsageScope(task, usageScope);
|
||||
@@ -124,50 +129,74 @@ public class AiGenerationTaskWorker
|
||||
applyUsage(task, usageScope);
|
||||
int totalTokens = safe(task.getTotalTokens());
|
||||
int costCents = safe(task.getCostCents());
|
||||
if (!finishClaimedTask(task, lockedBy))
|
||||
{
|
||||
return;
|
||||
}
|
||||
finalized = true;
|
||||
updateGenerationRecord(task, "1");
|
||||
clearTaskLock(task);
|
||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
||||
clearTaskLockInStore(task);
|
||||
insertEstimatedLedgerIfNeeded(task, usageScope, "1");
|
||||
aiQuotaService.settleCost(task.getUserId(), totalTokens, costCents);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
String message = sanitize(e.getMessage());
|
||||
task.setErrorMessage(message);
|
||||
task.setProgress(0);
|
||||
applyMeasuredUsage(task, usageScope);
|
||||
preserveOneClickFailureResult(task, e);
|
||||
if (retryPolicy.canRetry(message, attempts, safeMax(task.getMaxAttempts())))
|
||||
if (finalized)
|
||||
{
|
||||
task.setStatus("RETRY_WAITING");
|
||||
task.setCurrentStep("等待重试");
|
||||
task.setNextRetryTime(retryPolicy.nextRetryTime(attempts, new Date()));
|
||||
log.error("AI generation task post-finalization processing failed, taskId={}", taskId, e);
|
||||
}
|
||||
else
|
||||
{
|
||||
task.setStatus("FAILED");
|
||||
task.setCurrentStep("生成失败");
|
||||
task.setFinishedAt(new Date());
|
||||
updateGenerationRecord(task, "0");
|
||||
}
|
||||
clearTaskLock(task);
|
||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
||||
clearTaskLockInStore(task);
|
||||
insertEstimatedLedgerIfNeeded(task, usageScope, "0");
|
||||
if (safe(task.getTotalTokens()) > 0 || safe(task.getCostCents()) > 0)
|
||||
{
|
||||
aiQuotaService.settleCost(task.getUserId(), safe(task.getTotalTokens()), safe(task.getCostCents()));
|
||||
String message = sanitize(e.getMessage());
|
||||
task.setErrorMessage(message);
|
||||
task.setProgress(0);
|
||||
applyMeasuredUsage(task, usageScope);
|
||||
preserveOneClickFailureResult(task, e);
|
||||
if (retryPolicy.canRetry(message, attempts, safeMax(task.getMaxAttempts())))
|
||||
{
|
||||
task.setStatus("RETRY_WAITING");
|
||||
task.setCurrentStep("等待重试");
|
||||
task.setNextRetryTime(retryPolicy.nextRetryTime(attempts, new Date()));
|
||||
}
|
||||
else
|
||||
{
|
||||
task.setStatus("FAILED");
|
||||
task.setCurrentStep("生成失败");
|
||||
task.setFinishedAt(new Date());
|
||||
}
|
||||
if (!finishClaimedTask(task, lockedBy))
|
||||
{
|
||||
return;
|
||||
}
|
||||
finalized = true;
|
||||
if ("FAILED".equals(task.getStatus()))
|
||||
{
|
||||
updateGenerationRecord(task, "0");
|
||||
}
|
||||
insertEstimatedLedgerIfNeeded(task, usageScope, "0");
|
||||
if (safe(task.getTotalTokens()) > 0 || safe(task.getCostCents()) > 0)
|
||||
{
|
||||
aiQuotaService.settleCost(task.getUserId(), safe(task.getTotalTokens()), safe(task.getCostCents()));
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
cancelHeartbeat(heartbeat);
|
||||
aiQuotaService.releaseRunning(task.getUserId());
|
||||
if (finalized)
|
||||
{
|
||||
aiQuotaService.releaseRunning(task.getUserId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +217,8 @@ public class AiGenerationTaskWorker
|
||||
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);
|
||||
task.setErrorMessage(message);
|
||||
@@ -210,11 +240,15 @@ public class AiGenerationTaskWorker
|
||||
task.setStatus("FAILED");
|
||||
task.setCurrentStep("Generation failed");
|
||||
task.setFinishedAt(new Date());
|
||||
}
|
||||
if (!finishClaimedTask(task, lockedBy))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if ("FAILED".equals(task.getStatus()))
|
||||
{
|
||||
updateGenerationRecord(task, "0");
|
||||
}
|
||||
clearTaskLock(task);
|
||||
aiGenerationTaskMapper.updateAiGenerationTask(task);
|
||||
clearTaskLockInStore(task);
|
||||
insertEstimatedLedgerIfNeeded(task, usageScope, "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);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void releaseExpiredRunningTasks()
|
||||
@@ -566,23 +601,21 @@ public class AiGenerationTaskWorker
|
||||
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.setLockedUntil(null);
|
||||
}
|
||||
|
||||
private void clearTaskLockInStore(AiGenerationTask task)
|
||||
{
|
||||
if (task == null || task.getTaskId() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
aiGenerationTaskMapper.clearTaskLock(task.getTaskId());
|
||||
return true;
|
||||
}
|
||||
|
||||
private GenerateAppBlueprintRequest toAppBlueprintRequest(AiGenerationTaskCreateRequest source)
|
||||
|
||||
@@ -103,7 +103,7 @@ public class AiQuotaService
|
||||
|
||||
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)
|
||||
{
|
||||
return bucket;
|
||||
@@ -121,7 +121,8 @@ public class AiQuotaService
|
||||
bucket.setRunningLimit("DAY".equals(periodType) ? RUNNING_LIMIT : 0);
|
||||
bucket.setRunningCount(0);
|
||||
aiQuotaBucketMapper.insertQuotaBucket(bucket);
|
||||
return bucket;
|
||||
AiQuotaBucket locked = aiQuotaBucketMapper.selectQuotaBucketForUpdate(userId, periodType, periodKey);
|
||||
return locked == null ? bucket : locked;
|
||||
}
|
||||
|
||||
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.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.generator.util.ZipEntryPathValidator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -376,15 +377,6 @@ public class FrontProjectPreviewServiceImpl implements IFrontProjectPreviewServi
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String normalized = entryName.replace('\\', '/');
|
||||
while (normalized.startsWith("/"))
|
||||
{
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
if (normalized.startsWith("../") || normalized.contains("/../"))
|
||||
{
|
||||
throw new ServiceException("项目源码压缩包路径不合法");
|
||||
}
|
||||
return normalized;
|
||||
return ZipEntryPathValidator.requireRelative(entryName, "项目源码压缩包");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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 INT_TYPE_PATTERN = Pattern.compile("^int(\\(11\\))?$");
|
||||
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());
|
||||
}
|
||||
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())
|
||||
{
|
||||
throw new ServiceException("每张表至少需要一个字段");
|
||||
@@ -660,7 +670,8 @@ public class FrontProjectServiceImpl implements IFrontProjectService
|
||||
}
|
||||
table.setTableName(trimToNull(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(),
|
||||
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},
|
||||
error_message = '', update_time = now()
|
||||
where task_id = #{taskId} and stage_code = #{stageCode} and attempt_no = #{attemptNo}
|
||||
and status = 'RUNNING'
|
||||
</update>
|
||||
|
||||
<update id="failStageAttempt">
|
||||
@@ -60,6 +61,7 @@
|
||||
set status = 'FAILED', finished_at = #{finishedAt}, duration_millis = #{durationMillis},
|
||||
error_message = #{errorMessage}, update_time = now()
|
||||
where task_id = #{taskId} and stage_code = #{stageCode} and attempt_no = #{attemptNo}
|
||||
and status = 'RUNNING'
|
||||
</update>
|
||||
|
||||
<update id="linkGenerationRun">
|
||||
|
||||
@@ -275,6 +275,73 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
update_time = sysdate(),
|
||||
</trim>
|
||||
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 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}
|
||||
</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 into front_ai_quota_bucket
|
||||
(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
|
||||
(#{userId}, #{periodType}, #{periodKey}, #{taskLimit}, #{taskUsed}, #{tokenLimit}, #{tokenUsed},
|
||||
#{costLimitCents}, #{costUsedCents}, #{runningLimit}, #{runningCount}, sysdate(), sysdate())
|
||||
on duplicate key update bucket_id = last_insert_id(bucket_id)
|
||||
</insert>
|
||||
|
||||
<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}
|
||||
</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">
|
||||
<include refid="selectFrontProjectVo"/>
|
||||
<where>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,7 +19,23 @@
|
||||
"vue-router": "^3.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-eslint": "^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"
|
||||
},
|
||||
"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
|
||||
public void marksStageFailedWhenPreviewNeverBecomesRunning()
|
||||
public void throwsAfterPersistingFailureWhenPreviewNeverBecomesRunning()
|
||||
{
|
||||
IFrontProjectRunPreviewService runPreviewService = mock(IFrontProjectRunPreviewService.class);
|
||||
AiTaskCheckpointService checkpointService = mock(AiTaskCheckpointService.class);
|
||||
@@ -63,8 +63,18 @@ public class RunPreviewStageHandlerTest
|
||||
when(runPreviewService.start(7L, 20L)).thenReturn(status(ProjectRunPreviewStatus.STARTING));
|
||||
when(runPreviewService.status(7L, 20L)).thenReturn(status(ProjectRunPreviewStatus.STARTING));
|
||||
|
||||
handler.execute(context);
|
||||
ServiceException failure = null;
|
||||
try
|
||||
{
|
||||
handler.execute(context);
|
||||
}
|
||||
catch (ServiceException error)
|
||||
{
|
||||
failure = error;
|
||||
}
|
||||
|
||||
org.junit.Assert.assertNotNull(failure);
|
||||
assertEquals("运行预览启动超时", failure.getMessage());
|
||||
assertEquals(ProjectRunPreviewStatus.FAILED, context.getResult().getPreviewStatus());
|
||||
assertEquals(OneClickProjectGenerationResult.STAGE_VERIFY_AND_PREVIEW,
|
||||
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);
|
||||
when(aiInvocationPlanner.plan(any(String.class))).thenAnswer(invocation ->
|
||||
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
|
||||
@@ -368,8 +371,10 @@ public class AiGenerationTaskServiceImplTest
|
||||
|
||||
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
|
||||
request.setGenerateType("one_click_project");
|
||||
request.setProjectName("客户关系管理系统");
|
||||
request.setProjectDesc("");
|
||||
request.setProjectName("客户端伪造名称");
|
||||
request.setProjectDesc("客户端伪造描述");
|
||||
request.setCodeTemplate("client-template");
|
||||
request.setStylePreset("client-style");
|
||||
request.setExtraRequirements("");
|
||||
|
||||
AiGenerationTaskStatusResponse response = service.createTask(10L, 20L, request);
|
||||
@@ -383,6 +388,11 @@ public class AiGenerationTaskServiceImplTest
|
||||
assertEquals(Long.valueOf(10L), task.getUserId());
|
||||
assertEquals(Long.valueOf(20L), task.getProjectId());
|
||||
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());
|
||||
assertEquals(64, task.getStageManifestHash().length());
|
||||
assertTrue(task.getStageManifestJson().contains("front.flow_config"));
|
||||
@@ -418,6 +428,50 @@ public class AiGenerationTaskServiceImplTest
|
||||
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
|
||||
public void createTaskDefersDispatchUntilTransactionCommit()
|
||||
{
|
||||
@@ -550,7 +604,7 @@ public class AiGenerationTaskServiceImplTest
|
||||
AiGenerationTaskStatusResponse response = service.retryTask(7L, 10L, 99L);
|
||||
|
||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||
verify(aiGenerationTaskMapper).updateAiGenerationTask(taskCaptor.capture());
|
||||
verify(aiGenerationTaskMapper).retryFailedTask(taskCaptor.capture());
|
||||
AiGenerationTask retriedTask = taskCaptor.getValue();
|
||||
assertEquals("QUEUED", retriedTask.getStatus());
|
||||
assertEquals(Integer.valueOf(0), retriedTask.getProgress());
|
||||
@@ -588,12 +642,69 @@ public class AiGenerationTaskServiceImplTest
|
||||
{
|
||||
assertTrue(error.getMessage().contains("已有同类型任务正在生成"));
|
||||
verify(aiQuotaService, never()).reserve(anyLong());
|
||||
verify(aiGenerationTaskMapper, never()).updateAiGenerationTask(any(AiGenerationTask.class));
|
||||
verify(aiGenerationTaskMapper, never()).retryFailedTask(any(AiGenerationTask.class));
|
||||
return;
|
||||
}
|
||||
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
|
||||
public void retryTaskClearsStaleWorkerLockBeforeQueueing()
|
||||
{
|
||||
@@ -614,7 +725,7 @@ public class AiGenerationTaskServiceImplTest
|
||||
service.retryTask(7L, 10L, 99L);
|
||||
|
||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||
verify(aiGenerationTaskMapper).updateAiGenerationTask(taskCaptor.capture());
|
||||
verify(aiGenerationTaskMapper).retryFailedTask(taskCaptor.capture());
|
||||
AiGenerationTask retriedTask = taskCaptor.getValue();
|
||||
assertEquals("QUEUED", retriedTask.getStatus());
|
||||
assertNull(retriedTask.getLockedBy());
|
||||
@@ -639,13 +750,59 @@ public class AiGenerationTaskServiceImplTest
|
||||
AiGenerationTaskStatusResponse response = service.retryTask(7L, 10L, 99L);
|
||||
|
||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||
verify(aiGenerationTaskMapper).updateAiGenerationTask(taskCaptor.capture());
|
||||
verify(aiGenerationTaskMapper).retryFailedTask(taskCaptor.capture());
|
||||
AiGenerationTask retriedTask = taskCaptor.getValue();
|
||||
assertEquals("QUEUED", retriedTask.getStatus());
|
||||
assertEquals("", retriedTask.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)
|
||||
{
|
||||
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
|
||||
@@ -658,12 +815,22 @@ public class AiGenerationTaskServiceImplTest
|
||||
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()
|
||||
{
|
||||
FrontProject project = new FrontProject();
|
||||
project.setProjectId(10L);
|
||||
project.setUserId(7L);
|
||||
project.setProjectName("客户中心");
|
||||
project.setProjectDesc("管理客户资料");
|
||||
project.setCodeTemplate("qing");
|
||||
project.setStylePreset("dark-tech");
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -693,6 +860,9 @@ public class AiGenerationTaskServiceImplTest
|
||||
project.setProjectId(20L);
|
||||
project.setUserId(10L);
|
||||
project.setProjectName("客户关系管理系统");
|
||||
project.setProjectDesc("管理客户资料");
|
||||
project.setCodeTemplate("qing");
|
||||
project.setStylePreset("dark-tech");
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package com.ruoyi.generator.service.front;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -52,6 +53,8 @@ public class AiGenerationTaskWorkerTest
|
||||
setField("aiQuotaService", aiQuotaService);
|
||||
setField("retryPolicy", retryPolicy);
|
||||
setField("costCalculator", costCalculator);
|
||||
when(taskMapper.updateAiGenerationTask(any(AiGenerationTask.class))).thenReturn(1);
|
||||
when(taskMapper.finishClaimedTask(any(AiGenerationTask.class), anyString())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -66,13 +69,57 @@ public class AiGenerationTaskWorkerTest
|
||||
worker.processTask(99L);
|
||||
|
||||
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
|
||||
verify(taskMapper, atLeast(2)).updateAiGenerationTask(taskCaptor.capture());
|
||||
AiGenerationTask failed = taskCaptor.getAllValues().get(taskCaptor.getAllValues().size() - 1);
|
||||
verify(taskMapper).finishClaimedTask(taskCaptor.capture(), anyString());
|
||||
AiGenerationTask failed = taskCaptor.getValue();
|
||||
assertEquals("FAILED", failed.getStatus());
|
||||
assertEquals("Generation failed", failed.getCurrentStep());
|
||||
assertTrue(failed.getErrorMessage().contains("AssertionError"));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ public class AiQuotaServiceTest
|
||||
AiQuotaBucket day = bucket("DAY", "20260703");
|
||||
day.setRunningCount(1);
|
||||
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);
|
||||
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);
|
||||
when(aiGenerationTaskMapper.countActiveTasksForUser(7L)).thenReturn(0);
|
||||
|
||||
@@ -52,6 +52,8 @@ public class AiQuotaServiceTest
|
||||
InOrder inOrder = inOrder(aiGenerationTaskMapper, aiQuotaBucketMapper);
|
||||
inOrder.verify(aiGenerationTaskMapper).releaseExpiredRunningTasks();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,27 @@ public class FrontProjectPreviewServiceImplTest
|
||||
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
|
||||
public void rejectsFrontendStructureWhenProjectDisablesFrontend()
|
||||
{
|
||||
|
||||
@@ -238,6 +238,38 @@ public class FrontProjectServiceImplTest
|
||||
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
|
||||
public void saveDatabaseAddsDefaultAutoIncrementPrimaryKeyWhenMissing()
|
||||
{
|
||||
|
||||
@@ -68,12 +68,15 @@ public class QingTemplateSupportTest
|
||||
}
|
||||
|
||||
@Test
|
||||
public void qingPackageIncludesEcharts()
|
||||
public void qingPackageIncludesRuntimeAndLintTooling()
|
||||
{
|
||||
VelocityInitializer.initVelocity();
|
||||
String content = render("qing/vue-package.json.vm", VelocityUtils.prepareContextProject(project()));
|
||||
|
||||
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
|
||||
|
||||
@@ -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