完善一键生成项目的稳定性与验证

This commit is contained in:
王鹏
2026-07-15 19:28:17 +08:00
parent de98ea59b1
commit 90d7eff0b6
28 changed files with 1580 additions and 93 deletions

View File

@@ -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());

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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()))) {

View File

@@ -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)
{

View File

@@ -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)

View File

@@ -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)

View File

@@ -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, "项目源码压缩包");
}
}

View File

@@ -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())));
}

View File

@@ -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);
}
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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"
}
}
}

View File

@@ -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");
}
}

View File

@@ -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());

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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

View File

@@ -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);
}
}