feat: 完善 Prompt 管理与生成流程配置

This commit is contained in:
王鹏
2026-07-15 17:31:13 +08:00
parent 84d3c8fc05
commit de98ea59b1
93 changed files with 3474 additions and 980 deletions

View File

@@ -0,0 +1 @@
{"type":"server-started","port":64796,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:64796","screen_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\1712695621-1782569508\\content","state_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\1712695621-1782569508\\state"}

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1781882785790}

View File

@@ -0,0 +1 @@
807

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1783312098015}

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1783410216217}

View File

@@ -0,0 +1 @@
31444

View File

@@ -0,0 +1 @@
{"type":"server-started","port":60598,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60598","screen_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\codex-22612-20260707133826\\content","state_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\codex-22612-20260707133826\\state"}

View File

@@ -0,0 +1 @@
{"type":"server-started","port":51050,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:51050","screen_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\codex-frontend-reorg-1779977251\\content","state_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\codex-frontend-reorg-1779977251\\state"}

View File

@@ -0,0 +1,2 @@
{"type":"click","text":"A. 集成到现有生成工作台\n \n \n \n 菜单树\n 图书目录\n 我的借阅\n 订单记录\n \n \n 页面画布\n 搜索区:书名 / 分类 / 状态\n 工具栏:新增 / 导出 / 批量删除\n 表格列:书名、作者、库存、状态、操作\n \n \n 属性面板\n \n \n 保存设计\n \n \n \n \n \n \n A. 轻量集成版(推荐先做)\n 在 easycode-web 的生成页中新增“页面设计”步骤,复用现有 appBlueprint、database、businessActions。实现最快风险最低。","choice":"a","id":null,"timestamp":1779862739299}
{"type":"click","text":"A. 集成到现有生成工作台\n \n \n \n 菜单树\n 图书目录\n 我的借阅\n 订单记录\n \n \n 页面画布\n 搜索区:书名 / 分类 / 状态\n 工具栏:新增 / 导出 / 批量删除\n 表格列:书名、作者、库存、状态、操作\n \n \n 属性面板\n \n \n 保存设计\n \n \n \n \n \n \n A. 轻量集成版(推荐先做)\n 在 easycode-web 的生成页中新增“页面设计”步骤,复用现有 appBlueprint、database、businessActions。实现最快风险最低。","choice":"a","id":null,"timestamp":1779862742233}

View File

@@ -0,0 +1 @@
{"type":"server-started","port":57643,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:57643","screen_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\manual-31932-20260527140727\\content","state_dir":"D:\\codes\\yidaima\\RuoYi-Vue\\.superpowers\\brainstorm\\manual-31932-20260527140727\\state"}

View File

@@ -10,11 +10,9 @@
- 在线调试:草稿、已发布和已退役版本均可测试,记录输入、输出、模型、耗时、成功状态和人工评分。
- A/B 测试:同一模板下选择两个不可变版本,用相同输入运行对比,统计成功次数、平均耗时与人工评分。
## 初始化 V1
## 唯一来源
首次启用后,在 Prompt 中心点击“初始化 V1”。系统会把每个生成类型各自的业务 Prompt 写成 `V1` 并立即发布,包括对应任务、输出格式和业务约束的 System Prompt、Builder 合约、`{{input}}` User Prompt 模板和模型网关配置
初始化是幂等的:处理完全没有版本的模板;早期由系统生成且内容同质化的唯一 V1 会被安全修复。已有人工草稿、后续版本或实验版本的模板都会跳过,不覆盖后台维护内容。
Prompt 中心的数据库发布版是运行时唯一来源。系统不提供代码内置 Prompt、初始化 V1 或缺失回退;模板没有当前已发布版本、被停用或版本内容校验失败时,生成任务会明确失败。首个版本应通过部署 SQL 导入,或由管理员在 Prompt 中心创建并发布
## 数据库升级
@@ -25,13 +23,13 @@
3. `sql/factory_prompt_lab.sql`
4. `sql/prompt_registry_menu.sql`
然后将 `factory.prompt-registry.database-enabled`(环境变量 `FACTORY_PROMPT_REGISTRY_DATABASE_ENABLED`)设为 `true`,数据库中的已发布版本才会替代内置 Prompt。关闭时仍使用应用内置资源版本作为安全后备
完成升级后,确认每个正在使用的生成类型都有当前已发布版本。运行时始终读取数据库,不存在切换到代码内置 Prompt 的配置项
## 结构化上下文契约
前台 AI 生成入口只向 Prompt Registry 传递 JSON 动态上下文。角色设定、输出格式、字段约束、禁止项和纠错规则均由 Prompt 中心的 System Prompt 与 User Prompt 模板控制User Prompt 模板必须保留 `{{input}}`
当前可执行契约使用 `context-json:<generateType>:v1`。升级前发布`legacy-builder:*` 版本依赖 Java 内置规则,系统会将其视为过期版本并安全回退到资源模板;需要在 Prompt 中心基于新的 JSON 契约创建并发布新版本,才能重新由数据库版本接管
当前版本通常使用 `context-json:<generateType>:v1`契约和 Prompt 正文均随后台版本保存;升级前的 `legacy-builder:*` 版本不会触发代码回退,应在 Prompt 中心创建并发布完整的新版本
## 权限

View File

@@ -1195,20 +1195,20 @@ P1-B 已完成,见下方记录。
- 新增 `factory_prompt_template``factory_prompt_version`:模板保存稳定 Prompt code、生成类型、启用状态和当前发布指针版本保存不可变的 system Prompt、user Prompt builder 合约、provider/model、规范化 SHA-256 和发布状态。
- Prompt 版本正文创建后不可更新;版本号在模板行锁内单调递增,草稿发布与历史版本回滚会在同一事务中退役旧版本、切换状态并移动当前指针。
- 新增 `PromptFingerprintService`内置发布、数据库发布和发布服务复用同一规范化指纹算法;运行时会重新计算数据库内容哈希并拒绝漂移。
- 新增主运行实现 `DatabasePromptRegistry`显式开启数据库开关后按生成类型解析当前发布,并支持按 Prompt code/version 精确解析已发布或已退役版本,保证排队任务可重放。
- 数据库功能未开启、没有当前数据库发布或模板被停用时继续回落到内置 Prompt,确保迁移前和逐步启用期间不阻断现有生成链路
- 发布与回滚前校验内容指纹、当前代码真实实现的 user Prompt builder 合约和已注册 ModelGateway provider避免发布无法执行的虚假版本。
- 新增 `PromptFingerprintService`,数据库发布和发布服务复用同一规范化指纹算法;运行时会重新计算数据库内容哈希并拒绝漂移。
- 新增主运行实现 `DatabasePromptRegistry`始终按生成类型解析数据库当前发布,并支持按 Prompt code/version 精确解析已发布或已退役版本,保证排队任务可重放。
- 没有当前数据库发布或模板被停用时直接失败,不回落到代码内置 Prompt。
- 发布与回滚前校验内容指纹和已注册 ModelGateway provider避免发布无法执行的虚假版本。
- 新增带权限控制的模板列表、详情、创建、元数据编辑、版本创建、发布和回滚 APIPrompt code 与生成类型创建后不可变。
- 新增 RuoYi Prompt 管理页面和 API可搜索模板、查看当前发布与指纹、创建不可变草稿、发布草稿、回滚已退役版本,并从内置发布预填首个数据库版本
- 新增 RuoYi Prompt 管理页面和 API可搜索模板、查看当前发布与指纹、创建不可变草稿、发布草稿、回滚已退役版本;新版本只从当前后台版本复制内容
- 新增 `prompt_registry_menu.sql` 菜单权限脚本;`factory_prompt_registry.sql` 提供现有库升级和 11 类内置生成用途的模板元数据种子,完整建库脚本同步更新。
启用顺序:
1. 执行 `sql/factory_prompt_registry.sql` 创建表并写入模板元数据。
2. 执行 `sql/prompt_registry_menu.sql` 创建 Prompt 管理菜单和权限。
3. 设置 `FACTORY_PROMPT_REGISTRY_DATABASE_ENABLED=true`
4. 重启后端;首个数据库版本可在管理页基于内置发布创建并发布
3. 在管理页或部署 SQL 中为每个生成类型创建并发布数据库版本
4. 启动生成任务;缺少发布版时系统会明确报错
验证结果:

View File

@@ -0,0 +1,8 @@
{
"hash": "8f672fc6",
"configHash": "35975680",
"lockfileHash": "ed79ecfc",
"browserHash": "7a57d982",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -63,56 +63,77 @@
/>
<el-collapse v-model="oneClickAdvancedSections" class="advanced-settings">
<el-collapse-item title="高级设置" name="advanced">
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="视觉方案">
<el-radio-group v-model="projectForm.stylePreset" class="style-theme-group">
<el-radio
v-for="preset in availablePageStylePresets"
:key="preset.code"
:label="preset.code"
class="style-theme-option"
>
<span class="style-theme-card" :style="themePreviewStyle(preset)">
<span class="style-theme-preview" :class="solutionPreviewClasses(preset)" :style="themePreviewStyle(preset)" aria-hidden="true">
<span class="theme-preview-bar" />
<span class="theme-preview-body">
<span class="theme-preview-media" />
<span class="theme-preview-lines">
<span />
<span />
<span />
</span>
</span>
<span class="theme-preview-swatches">
<template #title>
<div class="advanced-settings-title">
<span class="advanced-settings-title-icon" aria-hidden="true" />
<span class="advanced-settings-title-copy">
<strong>高级设置</strong>
<small>视觉风格生成范围与代码模板</small>
</span>
<span class="advanced-settings-summary">
{{ selectedStylePreset?.label || '未选择视觉方案' }}
<i />
{{ projectForm.frontendEnabled ? '包含用户前台' : '仅后台管理' }}
</span>
</div>
</template>
<div class="advanced-settings-body">
<el-form-item label="视觉方案" class="advanced-setting-section visual-theme-setting">
<p class="advanced-setting-help">统一前台与后台的导航色彩和内容布局</p>
<el-radio-group v-model="projectForm.stylePreset" class="style-theme-group">
<el-radio
v-for="preset in availablePageStylePresets"
:key="preset.code"
:label="preset.code"
class="style-theme-option"
>
<span class="style-theme-card" :style="themePreviewStyle(preset)">
<span class="style-theme-preview" :class="solutionPreviewClasses(preset)" :style="themePreviewStyle(preset)" aria-hidden="true">
<span class="theme-preview-bar" />
<span class="theme-preview-body">
<span class="theme-preview-media" />
<span class="theme-preview-lines">
<span />
<span />
<span />
</span>
</span>
<span class="style-theme-copy">
<strong>{{ preset.label }}</strong>
<small>{{ preset.description }}</small>
<span class="style-solution-tags">
<em v-for="tag in solutionMetaLabels(preset)" :key="tag">{{ tag }}</em>
</span>
<span class="theme-preview-swatches">
<span />
<span />
<span />
</span>
</span>
</el-radio>
</el-radio-group>
<span class="style-theme-copy">
<strong>{{ preset.label }}</strong>
<small>{{ preset.description }}</small>
<span class="style-solution-tags">
<em v-for="tag in solutionMetaLabels(preset)" :key="tag">{{ tag }}</em>
</span>
</span>
</span>
</el-radio>
</el-radio-group>
</el-form-item>
<div class="advanced-secondary-grid">
<el-form-item label="用户前台" class="advanced-setting-section frontend-setting-section">
<p class="advanced-setting-help">选择完整双端项目或只保留后台管理端</p>
<div class="frontend-setting-control">
<span class="frontend-setting-copy">
<strong>{{ projectForm.frontendEnabled ? '生成用户前台' : '仅后台管理' }}</strong>
<small>{{ projectForm.frontendEnabled ? '生成面向用户的页面与菜单' : '不生成用户侧页面和菜单' }}</small>
</span>
<el-switch
v-model="projectForm.frontendEnabled"
aria-label="生成用户前台"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="用户前台">
<el-switch
v-model="projectForm.frontendEnabled"
active-text="生成用户前台"
inactive-text="仅后台管理"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="代码模板">
<el-form-item label="代码模板" class="advanced-setting-section code-template-setting">
<p class="advanced-setting-help">决定生成项目的技术栈和基础目录结构</p>
<div class="code-template-panel" :style="themePreviewStyle(selectedStylePreset)">
<div class="code-template-cover">
<img
@@ -120,11 +141,16 @@
:src="templatePreviewUrl(selectedCodeTemplateBundle)"
:alt="selectedCodeTemplateBundle.bundleName || selectedCodeTemplateBundle.bundleCode"
/>
<span v-else>未上传展示图</span>
<span v-else class="code-template-placeholder">
<i aria-hidden="true" />
<small>未上传展示图</small>
</span>
</div>
<div class="code-template-control">
<strong>{{ selectedCodeTemplateBundle.bundleName || selectedCodeTemplateBundle.bundleCode }}</strong>
<small>{{ selectedCodeTemplateBundle.description || '按当前模板生成后端、前台、后台和 SQL' }}</small>
<span class="code-template-copy">
<strong>{{ selectedCodeTemplateBundle.bundleName || selectedCodeTemplateBundle.bundleCode }}</strong>
<small>{{ selectedCodeTemplateBundle.description || '按当前模板生成后端、前台、后台和 SQL' }}</small>
</span>
<el-select v-model="projectForm.codeTemplate" class="code-template-select" filterable>
<el-option
v-for="bundle in codeTemplateBundles"
@@ -151,8 +177,8 @@
</div>
</div>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</el-collapse-item>
</el-collapse>
<el-form-item class="one-click-action-item">
@@ -3350,8 +3376,198 @@ onBeforeUnmount(() => {
}
.advanced-settings {
overflow: hidden;
max-width: 1180px;
margin-bottom: 16px;
border: 1px solid #dfe7f1;
border-radius: 12px;
background: #fff;
:deep(.el-collapse-item__header) {
min-height: 64px;
height: auto;
padding: 0 16px;
border-bottom-color: #e7edf5;
background: #fff;
}
:deep(.el-collapse-item__header:not(.is-active)) {
border-bottom-color: transparent;
}
:deep(.el-collapse-item__arrow) {
margin-left: 16px;
color: #64748b;
font-size: 14px;
}
:deep(.el-collapse-item__wrap) {
border-bottom: 0;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
color: inherit;
}
}
.advanced-settings-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
width: 100%;
padding-right: 4px;
}
.advanced-settings-title-icon {
position: relative;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 9px;
background: linear-gradient(145deg, #eff6ff, #dbeafe);
&::before,
&::after {
position: absolute;
left: 8px;
width: 16px;
height: 3px;
border-radius: 999px;
background: #3b82f6;
content: '';
}
&::before {
top: 10px;
box-shadow: 0 9px 0 #60a5fa;
}
&::after {
top: 8px;
left: 17px;
width: 4px;
height: 7px;
background: #fff;
box-shadow: -6px 9px 0 #fff;
}
}
.advanced-settings-title-copy {
display: grid;
gap: 3px;
min-width: 0;
strong {
color: #172033;
font-size: 15px;
line-height: 1.25;
}
small {
color: #8793a5;
font-size: 12px;
line-height: 1.25;
}
}
.advanced-settings-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-left: auto;
overflow: hidden;
color: #64748b;
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
i {
flex: 0 0 auto;
width: 3px;
height: 3px;
border-radius: 50%;
background: #cbd5e1;
}
}
.advanced-settings-body {
display: grid;
gap: 12px;
padding: 16px;
background: #f7f9fc;
}
.advanced-setting-section {
box-sizing: border-box;
height: 100%;
margin-bottom: 0;
padding: 16px;
border: 1px solid #e3eaf3;
border-radius: 10px;
background: #fff;
:deep(.el-form-item__label) {
height: auto;
padding: 0;
color: #172033;
font-size: 14px;
font-weight: 700;
line-height: 1.35;
}
:deep(.el-form-item__content) {
display: block;
min-width: 0;
line-height: normal;
}
}
.advanced-setting-help {
margin: 5px 0 14px;
color: #7a8799;
font-size: 12px;
line-height: 1.5;
}
.advanced-secondary-grid {
display: grid;
grid-template-columns: minmax(280px, 0.72fr) minmax(520px, 1.28fr);
gap: 12px;
align-items: stretch;
}
.frontend-setting-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 72px;
padding: 14px;
border: 1px solid #e8eef6;
border-radius: 9px;
background: #f8fafc;
}
.frontend-setting-copy {
display: grid;
gap: 5px;
min-width: 0;
strong {
color: #243047;
font-size: 14px;
line-height: 1.35;
}
small {
color: #7a8799;
font-size: 12px;
line-height: 1.45;
}
}
.one-click-primary-actions {
@@ -3439,69 +3655,101 @@ onBeforeUnmount(() => {
.code-template-panel {
display: grid;
grid-template-columns: minmax(190px, 260px) minmax(0, 1fr);
gap: 12px;
grid-template-columns: 156px minmax(0, 1fr);
gap: 14px;
width: 100%;
max-width: 980px;
padding: 10px;
border: 1px solid var(--theme-border);
border-radius: 8px;
background: var(--theme-bg);
min-width: 0;
}
.code-template-cover {
display: grid;
place-items: center;
min-width: 0;
min-height: 132px;
height: 112px;
overflow: hidden;
border: 1px solid var(--theme-border);
border-radius: var(--theme-radius);
background: var(--theme-surface);
box-shadow: var(--theme-shadow);
border-radius: 8px;
background: var(--theme-soft);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
span {
color: #94a3b8;
font-size: 13px;
.code-template-placeholder {
display: grid;
justify-items: center;
gap: 8px;
color: #94a3b8;
i {
position: relative;
display: block;
width: 38px;
height: 28px;
border: 1px solid var(--theme-border);
border-radius: 6px;
background: var(--theme-surface);
&::before {
position: absolute;
right: 6px;
bottom: 6px;
left: 6px;
height: 8px;
border-radius: 3px;
background: linear-gradient(135deg, transparent 34%, var(--theme-primary) 35% 58%, var(--theme-accent) 59%);
opacity: 0.5;
content: '';
}
}
small {
font-size: 11px;
}
}
.code-template-control {
display: grid;
gap: 8px;
gap: 12px;
min-width: 0;
align-content: space-between;
}
.code-template-copy {
display: grid;
gap: 4px;
min-width: 0;
align-content: center;
strong {
min-width: 0;
overflow: hidden;
color: #172033;
font-size: 14px;
font-size: 15px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
display: -webkit-box;
overflow: hidden;
color: #667085;
font-size: 12px;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
.style-theme-group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 12px;
align-items: stretch;
width: 100%;
max-width: 1180px;
}
.style-theme-alert {
@@ -3512,7 +3760,7 @@ onBeforeUnmount(() => {
position: relative;
display: flex;
width: 100%;
height: 112px;
min-height: 138px;
margin-right: 0;
white-space: normal;
@@ -3530,20 +3778,38 @@ onBeforeUnmount(() => {
&.is-checked .style-theme-card {
border-color: var(--theme-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.14);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-primary) 15%, transparent);
&::after {
position: absolute;
top: 10px;
right: 10px;
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: #fff;
background: var(--theme-primary);
font-size: 12px;
font-weight: 700;
content: '✓';
}
}
}
.style-theme-card {
position: relative;
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 10px;
padding: 12px;
border: 1px solid #dce5ef;
border-radius: 8px;
border-radius: 10px;
background: #fff;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
@@ -3556,8 +3822,9 @@ onBeforeUnmount(() => {
.style-theme-preview {
display: grid;
gap: 6px;
width: 88px;
height: 68px;
width: 92px;
height: 76px;
box-sizing: border-box;
padding: 8px;
border: 1px solid var(--theme-border);
border-radius: 8px;
@@ -3667,16 +3934,17 @@ onBeforeUnmount(() => {
.style-theme-copy {
display: grid;
grid-template-rows: auto 35px auto;
gap: 4px;
grid-template-rows: auto minmax(34px, auto) auto;
gap: 7px;
min-width: 0;
align-content: center;
strong {
min-width: 0;
padding-right: 22px;
overflow: hidden;
color: #172033;
font-size: 14px;
font-size: 15px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
@@ -3694,19 +3962,24 @@ onBeforeUnmount(() => {
}
.style-solution-tags {
display: flex;
flex-wrap: wrap;
display: grid;
gap: 4px;
margin-top: 2px;
justify-items: start;
min-width: 0;
em {
box-sizing: border-box;
max-width: 100%;
padding: 2px 6px;
overflow: hidden;
border-radius: 999px;
color: var(--theme-primary);
background: var(--theme-soft);
font-size: 10px;
font-style: normal;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -4078,11 +4351,25 @@ onBeforeUnmount(() => {
gap: 18px;
}
@media (max-width: 1100px) {
.advanced-secondary-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.code-template-panel {
grid-template-columns: 1fr;
}
.code-template-cover {
height: 150px;
}
.advanced-settings-summary {
display: none;
}
.mode-switcher-panel {
align-items: stretch;
flex-direction: column;
@@ -4111,4 +4398,29 @@ onBeforeUnmount(() => {
flex-direction: column;
}
}
@media (max-width: 560px) {
.advanced-settings-body {
padding: 10px;
}
.advanced-setting-section {
padding: 13px;
}
.advanced-settings-title-copy small {
display: none;
}
.style-theme-card {
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
padding: 10px;
}
.style-theme-preview {
width: 82px;
height: 72px;
}
}
</style>

View File

@@ -440,12 +440,15 @@ test('generate page keeps visual themes compact and removes the large impact pre
assert.doesNotMatch(source, /class="style-theme-note"/)
})
test('generate page gives every visual theme card the same dimensions', () => {
test('generate page keeps visual theme cards readable and aligned in the new settings grid', () => {
const source = readSource('views/GenerateView.vue')
assert.match(source, /grid-template-columns: repeat\(auto-fill, minmax\(min\(220px, 100%\), 1fr\)\)/)
assert.match(source, /\.style-theme-option \{[\s\S]*?width: 100%;[\s\S]*?height: 112px;/)
assert.match(source, /class="advanced-settings-body"/)
assert.match(source, /class="advanced-secondary-grid"/)
assert.match(source, /grid-template-columns: repeat\(auto-fill, minmax\(min\(260px, 100%\), 1fr\)\)/)
assert.match(source, /\.style-theme-option \{[\s\S]*?width: 100%;[\s\S]*?min-height: 138px;/)
assert.match(source, /\.style-theme-card \{[\s\S]*?width: 100%;[\s\S]*?height: 100%;/)
assert.match(source, /\.style-solution-tags \{[\s\S]*?display: grid;/)
})
test('generate page displays one-click business loop report', () => {

View File

@@ -40,9 +40,6 @@ easycode:
pids-limit: ${EASYCODE_PREVIEW_PIDS_LIMIT:256}
factory:
prompt-registry:
database-enabled: ${FACTORY_PROMPT_REGISTRY_DATABASE_ENABLED:true}
builtin-fallback-enabled: ${FACTORY_PROMPT_REGISTRY_BUILTIN_FALLBACK_ENABLED:false}
plugin-registry:
database-enabled: ${FACTORY_PLUGIN_REGISTRY_DATABASE_ENABLED:false}
plugin-execution:

View File

@@ -1,19 +0,0 @@
package com.ruoyi.generator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "factory.prompt-registry")
public class PromptRegistryProperties
{
private Boolean databaseEnabled = false;
private Boolean builtinFallbackEnabled = false;
public Boolean getDatabaseEnabled() { return databaseEnabled; }
public void setDatabaseEnabled(Boolean databaseEnabled) { this.databaseEnabled = databaseEnabled; }
public boolean isDatabaseEnabled() { return Boolean.TRUE.equals(databaseEnabled); }
public Boolean getBuiltinFallbackEnabled() { return builtinFallbackEnabled; }
public void setBuiltinFallbackEnabled(Boolean builtinFallbackEnabled) { this.builtinFallbackEnabled = builtinFallbackEnabled; }
public boolean isBuiltinFallbackEnabled() { return Boolean.TRUE.equals(builtinFallbackEnabled); }
}

View File

@@ -91,12 +91,4 @@ public class PromptTemplateController extends BaseController
promptVersionId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('generator:prompt:version')")
@Log(title = "Prompt V1 初始化", businessType = BusinessType.INSERT)
@PostMapping("/initialize-v1")
public AjaxResult initializeV1()
{
int count = promptTemplateService.initializeBuiltinV1(getUsername());
return AjaxResult.success("已初始化或修复 " + count + " 个 Prompt V1", count);
}
}

View File

@@ -1,169 +0,0 @@
package com.ruoyi.generator.factory.ai.prompt;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
@Service
public class BuiltinPromptRegistry implements PromptRegistry
{
public static final String DEFAULT_SYSTEM_PROMPT = load("default", "system");
public static final String REQUIREMENT_SYSTEM_PROMPT = load("requirement", "system");
public static final String REQUIREMENT_USER_PROMPT_TEMPLATE = load("requirement", "user");
public static final String APP_BLUEPRINT_SYSTEM_PROMPT = load("app_blueprint", "system");
public static final String APP_BLUEPRINT_USER_PROMPT_TEMPLATE = load("app_blueprint", "user");
public static final String FLOW_CONFIG_SYSTEM_PROMPT = load("flow_config", "system");
public static final String FLOW_CONFIG_USER_PROMPT_TEMPLATE = load("flow_config", "user");
public static final String DATABASE_SYSTEM_PROMPT = load("database", "system");
public static final String DATABASE_USER_PROMPT_TEMPLATE = load("database", "user");
public static final String DIAGRAM_SYSTEM_PROMPT = load("diagram_spec", "system");
public static final String DIAGRAM_USER_PROMPT_TEMPLATE = load("diagram_spec", "user");
public static final String PAPER_OUTLINE_SYSTEM_PROMPT = load("paper_outline", "system");
public static final String PAPER_OUTLINE_USER_PROMPT_TEMPLATE = load("paper_outline", "user");
public static final String PAPER_DRAFT_SYSTEM_PROMPT = load("paper_draft", "system");
public static final String PAPER_DRAFT_USER_PROMPT_TEMPLATE = load("paper_draft", "user");
public static final String CODE_ANALYSIS_SYSTEM_PROMPT = load("code_analysis", "system");
public static final String CODE_ANALYSIS_USER_PROMPT_TEMPLATE = load("code_analysis", "user");
public static final String DATABASE_CHANGE_SYNC_SYSTEM_PROMPT = load("database_change_sync", "system");
public static final String DATABASE_CHANGE_SYNC_USER_PROMPT_TEMPLATE = load("database_change_sync", "user");
public static final String ONE_CLICK_PROJECT_SYSTEM_PROMPT = load("one_click_project", "system");
public static final String ONE_CLICK_PROJECT_USER_PROMPT_TEMPLATE = load("one_click_project", "user");
public static final String VERSION = "1.0.0";
public static final String PIPELINE_RELEASE_CODE = "builtin-one-click-1.0.0";
private final Map<String, PromptRelease> byGenerateType;
private final Map<String, PromptRelease> byRelease;
@Autowired
public BuiltinPromptRegistry(PromptFingerprintService fingerprintService)
{
Map<String, PromptRelease> types = new LinkedHashMap<String, PromptRelease>();
register(types, release(fingerprintService, "requirement", REQUIREMENT_SYSTEM_PROMPT,
"context-json:requirement:v1", REQUIREMENT_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "app_blueprint", APP_BLUEPRINT_SYSTEM_PROMPT,
"context-json:app_blueprint:v1", APP_BLUEPRINT_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "flow_config", FLOW_CONFIG_SYSTEM_PROMPT,
"context-json:flow_config:v1", FLOW_CONFIG_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "database", DATABASE_SYSTEM_PROMPT,
"context-json:database:v1", DATABASE_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "diagram_spec", DIAGRAM_SYSTEM_PROMPT,
"context-json:diagram_spec:v1", DIAGRAM_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "paper_outline", PAPER_OUTLINE_SYSTEM_PROMPT,
"context-json:paper_outline:v1", PAPER_OUTLINE_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "paper_draft", PAPER_DRAFT_SYSTEM_PROMPT,
"context-json:paper_draft:v1", PAPER_DRAFT_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "code_analysis", CODE_ANALYSIS_SYSTEM_PROMPT,
"context-json:code_analysis:v1", CODE_ANALYSIS_USER_PROMPT_TEMPLATE));
register(types, release(fingerprintService, "database_change_sync", DATABASE_CHANGE_SYNC_SYSTEM_PROMPT,
"context-json:database_change_sync:v1", DATABASE_CHANGE_SYNC_USER_PROMPT_TEMPLATE));
register(types, pipelineRelease(fingerprintService));
byGenerateType = Collections.unmodifiableMap(types);
Map<String, PromptRelease> releases = new LinkedHashMap<String, PromptRelease>();
for (PromptRelease item : types.values())
{
releases.put(key(item.getPromptCode(), item.getVersion()), item);
}
byRelease = Collections.unmodifiableMap(releases);
}
@Override
public PromptRelease resolve(String generateType)
{
PromptRelease release = byGenerateType.get(StringUtils.defaultString(generateType));
if (release == null)
{
throw new ServiceException("No published Prompt release for generation type: "
+ StringUtils.defaultString(generateType));
}
return release;
}
@Override
public PromptRelease resolve(String promptCode, String version)
{
PromptRelease release = byRelease.get(key(promptCode, version));
if (release == null)
{
throw new ServiceException("Prompt release does not exist: "
+ StringUtils.defaultString(promptCode) + "@" + StringUtils.defaultString(version));
}
return release;
}
public String managementSystemPrompt(String generateType)
{
return resolve(generateType).getSystemPrompt();
}
public String managementUserPromptTemplate(String generateType)
{
return resolve(generateType).getUserPromptTemplate();
}
private PromptRelease pipelineRelease(PromptFingerprintService fingerprintService)
{
return release(fingerprintService, "one_click_project", ONE_CLICK_PROJECT_SYSTEM_PROMPT,
"pipeline:app_blueprint,flow_config,database,generate,preview:v2",
ONE_CLICK_PROJECT_USER_PROMPT_TEMPLATE);
}
private PromptRelease release(PromptFingerprintService fingerprintService,
String generateType, String systemPrompt, String userPromptContract,
String userPromptTemplate)
{
PromptRelease release = new PromptRelease();
release.setGenerateType(generateType);
release.setPromptCode("front." + generateType);
release.setVersion(VERSION);
release.setSystemPrompt(systemPrompt);
release.setUserPromptContract(userPromptContract);
release.setUserPromptTemplate(userPromptTemplate);
release.setProviderCode("deepseek");
release.setPipelineReleaseCode(PIPELINE_RELEASE_CODE);
release.setFingerprint(fingerprintService.fingerprint(release.getGenerateType(),
release.getPromptCode(), release.getVersion(), release.getSystemPrompt(),
release.getUserPromptContract(), release.getUserPromptTemplate()));
return release;
}
private void register(Map<String, PromptRelease> releases, PromptRelease release)
{
if (releases.put(release.getGenerateType(), release) != null)
{
throw new ServiceException("Duplicate built-in Prompt generation type: " + release.getGenerateType());
}
}
private String key(String promptCode, String version)
{
return StringUtils.defaultString(promptCode) + "@" + StringUtils.defaultString(version);
}
private static String load(String generateType, String role)
{
String path = "/factory-prompts/" + generateType + "/" + role + ".txt";
try (InputStream input = BuiltinPromptRegistry.class.getResourceAsStream(path))
{
if (input == null)
{
throw new ServiceException("Built-in Prompt resource does not exist: " + path);
}
return StreamUtils.copyToString(input, StandardCharsets.UTF_8).trim();
}
catch (IOException e)
{
throw new ServiceException("Unable to read built-in Prompt resource: " + path)
.setDetailMessage(e.getMessage());
}
}
}

View File

@@ -5,7 +5,6 @@ import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.config.PromptRegistryProperties;
import com.ruoyi.generator.factory.ai.prompt.model.PromptVersion;
import com.ruoyi.generator.mapper.factory.PromptRegistryMapper;
@@ -14,33 +13,23 @@ import com.ruoyi.generator.mapper.factory.PromptRegistryMapper;
public class DatabasePromptRegistry implements PromptRegistry
{
private final PromptRegistryMapper mapper;
private final BuiltinPromptRegistry builtinRegistry;
private final PromptFingerprintService fingerprintService;
private final PromptRegistryProperties properties;
@Autowired
public DatabasePromptRegistry(PromptRegistryMapper mapper,
BuiltinPromptRegistry builtinRegistry,
PromptFingerprintService fingerprintService,
PromptRegistryProperties properties)
PromptFingerprintService fingerprintService)
{
this.mapper = mapper;
this.builtinRegistry = builtinRegistry;
this.fingerprintService = fingerprintService;
this.properties = properties;
}
@Override
public PromptRelease resolve(String generateType)
{
if (!properties.isDatabaseEnabled())
{
return builtinRegistry.resolve(generateType);
}
PromptVersion version = mapper.selectPublishedByGenerateType(generateType);
if (version == null)
{
return missingDatabaseRelease(generateType, null);
throw missingDatabaseRelease(generateType, null);
}
return toRelease(version);
}
@@ -48,27 +37,16 @@ public class DatabasePromptRegistry implements PromptRegistry
@Override
public PromptRelease resolve(String promptCode, String version)
{
if (!properties.isDatabaseEnabled())
{
return builtinRegistry.resolve(promptCode, version);
}
PromptVersion stored = mapper.selectExecutableByCodeAndVersion(promptCode, version);
if (stored == null)
{
return missingDatabaseRelease(promptCode, version);
throw missingDatabaseRelease(promptCode, version);
}
return toRelease(stored);
}
private PromptRelease toRelease(PromptVersion version)
{
PromptRelease supported = builtinRegistry.resolve(version.getGenerateType());
if (!StringUtils.equals(supported.getUserPromptContract(),
version.getUserPromptContract()))
{
throw new ServiceException("Stored User Prompt contract is not implemented: "
+ version.getUserPromptContract());
}
String actualHash = fingerprintService.fingerprint(version.getGenerateType(),
version.getPromptCode(), version.getVersion(), version.getSystemPrompt(),
version.getUserPromptContract(), version.getUserPromptTemplate());
@@ -91,15 +69,10 @@ public class DatabasePromptRegistry implements PromptRegistry
return release;
}
private PromptRelease missingDatabaseRelease(String codeOrType, String version)
private ServiceException missingDatabaseRelease(String codeOrType, String version)
{
if (properties.isBuiltinFallbackEnabled())
{
return version == null ? builtinRegistry.resolve(codeOrType)
: builtinRegistry.resolve(codeOrType, version);
}
String identity = version == null ? StringUtils.defaultString(codeOrType)
: StringUtils.defaultString(codeOrType) + "@" + StringUtils.defaultString(version);
throw new ServiceException("Database Prompt release does not exist: " + identity);
return new ServiceException("Published managed Prompt release does not exist: " + identity);
}
}

View File

@@ -2,13 +2,11 @@ package com.ruoyi.generator.factory.ai.prompt.model;
import java.util.ArrayList;
import java.util.List;
import com.ruoyi.generator.factory.ai.prompt.PromptRelease;
public class PromptTemplateDetail
{
private PromptTemplate template;
private List<PromptVersion> versions = new ArrayList<PromptVersion>();
private PromptRelease builtinRelease;
public PromptTemplate getTemplate() { return template; }
public void setTemplate(PromptTemplate template) { this.template = template; }
@@ -17,6 +15,4 @@ public class PromptTemplateDetail
{
this.versions = versions == null ? new ArrayList<PromptVersion>() : versions;
}
public PromptRelease getBuiltinRelease() { return builtinRelease; }
public void setBuiltinRelease(PromptRelease builtinRelease) { this.builtinRelease = builtinRelease; }
}

View File

@@ -9,6 +9,7 @@ import com.ruoyi.generator.domain.front.dto.GenerateAppBlueprintRequest;
import com.ruoyi.generator.domain.front.dto.GenerateFlowConfigRequest;
import com.ruoyi.generator.domain.front.dto.OneClickProjectGenerationResult;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig;
import com.ruoyi.generator.service.front.FlowConfigPageBindingReconciler;
import com.ruoyi.generator.service.front.IAiGenerateService;
/** One visible design step: application blueprint plus the small flow_config. */
@@ -16,11 +17,14 @@ import com.ruoyi.generator.service.front.IAiGenerateService;
public class DesignStageHandler implements OneClickGenerationStageHandler
{
private final IAiGenerateService aiGenerateService;
private final FlowConfigPageBindingReconciler pageBindingReconciler;
@Autowired
public DesignStageHandler(IAiGenerateService aiGenerateService)
public DesignStageHandler(IAiGenerateService aiGenerateService,
FlowConfigPageBindingReconciler pageBindingReconciler)
{
this.aiGenerateService = aiGenerateService;
this.pageBindingReconciler = pageBindingReconciler;
}
public String stageCode() { return OneClickProjectGenerationResult.STAGE_DESIGN; }
@@ -49,6 +53,8 @@ public class DesignStageHandler implements OneClickGenerationStageHandler
flowRequest.setAppBlueprint(context.getAppBlueprintJson());
SimpleFlowConfig flowConfig = aiGenerateService.generateFlowConfig(context.getTask().getUserId(),
context.getTask().getProjectId(), flowRequest);
pageBindingReconciler.reconcile(flowConfig, app);
context.setAppBlueprintJson(JSON.toJSONString(app));
context.setFlowConfig(flowConfig);
}
}

View File

@@ -39,11 +39,51 @@ public class FlowConfigSanitizer
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config))
{
repairMetricStatuses(flow);
repairSelfUpdateRelationFields(flow);
repairEffectValues(flow);
}
return config;
}
/**
* An update_related effect aimed at its owner row still needs the owner's
* primary-key field so the renderer can reload that row. AI responses often
* omit it because no cross-table relationship is involved. Infer it only
* from the action's request fields and only when target and owner are the
* same table; cross-table relationships must remain explicit.
*/
private void repairSelfUpdateRelationFields(SimpleFlowConfig config)
{
for (SimpleFlowConfig.Action action : config.getActions())
{
if (action == null) continue;
String ownerTable = StringUtils.defaultIfBlank(action.getOwnerTable(), config.getMainTable());
for (SimpleFlowConfig.Effect effect : action.getEffects())
{
if (effect == null || !"update_related".equals(effect.getType())
|| StringUtils.isNotBlank(effect.getRelationField())
|| !StringUtils.equals(ownerTable, effect.getTable()))
{
continue;
}
String relationField = ownerRequestId(action);
if (StringUtils.isNotBlank(relationField)) effect.setRelationField(relationField);
}
}
}
private String ownerRequestId(SimpleFlowConfig.Action action)
{
List<String> requestFields = action.getRequestFields();
if (requestFields == null || requestFields.isEmpty()) return null;
for (String requestField : requestFields)
{
if ("id".equals(requestField)) return requestField;
}
if (requestFields.size() != 1) return null;
return StringUtils.trim(requestFields.get(0));
}
private void repairEffectValues(SimpleFlowConfig config)
{
for (SimpleFlowConfig.Action action : config.getActions())

View File

@@ -32,8 +32,6 @@ public interface PromptRegistryMapper
int insertPromptVersion(PromptVersion version);
int updateInitializedV1(PromptVersion version);
int retirePublishedVersions(@Param("promptTemplateId") Long promptTemplateId,
@Param("excludeVersionId") Long excludeVersionId);

View File

@@ -20,6 +20,4 @@ public interface IPromptTemplateService
int publishPromptVersion(Long promptTemplateId, Long promptVersionId, String operator);
int rollbackPromptVersion(Long promptTemplateId, Long promptVersionId, String operator);
int initializeBuiltinV1(String operator);
}

View File

@@ -10,8 +10,6 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.factory.ai.model.ModelGatewayRouter;
import com.ruoyi.generator.factory.ai.prompt.PromptFingerprintService;
import com.ruoyi.generator.factory.ai.prompt.BuiltinPromptRegistry;
import com.ruoyi.generator.factory.ai.prompt.PromptRelease;
import com.ruoyi.generator.factory.ai.prompt.PromptTemplateRenderer;
import com.ruoyi.generator.factory.ai.prompt.model.PromptTemplate;
import com.ruoyi.generator.factory.ai.prompt.model.PromptTemplateDetail;
@@ -22,23 +20,18 @@ import com.ruoyi.generator.mapper.factory.PromptRegistryMapper;
public class PromptTemplateServiceImpl implements IPromptTemplateService
{
private static final Pattern PROMPT_CODE = Pattern.compile("^[a-z0-9._-]+$");
private static final String INITIAL_V1_REMARK = "由当前代码内置 Prompt 初始化";
private static final String BUSINESS_V1_REMARK = "由当前代码内置业务 Prompt 初始化";
private final PromptRegistryMapper mapper;
private final PromptFingerprintService fingerprintService;
private final ModelGatewayRouter gatewayRouter;
private final BuiltinPromptRegistry builtinPromptRegistry;
@Autowired
public PromptTemplateServiceImpl(PromptRegistryMapper mapper,
PromptFingerprintService fingerprintService, ModelGatewayRouter gatewayRouter,
BuiltinPromptRegistry builtinPromptRegistry)
PromptFingerprintService fingerprintService, ModelGatewayRouter gatewayRouter)
{
this.mapper = mapper;
this.fingerprintService = fingerprintService;
this.gatewayRouter = gatewayRouter;
this.builtinPromptRegistry = builtinPromptRegistry;
}
@Override
@@ -55,7 +48,6 @@ public class PromptTemplateServiceImpl implements IPromptTemplateService
PromptTemplateDetail detail = new PromptTemplateDetail();
detail.setTemplate(template);
detail.setVersions(versions == null ? Collections.<PromptVersion>emptyList() : versions);
detail.setBuiltinRelease(builtinPromptRegistry.resolve(template.getGenerateType()));
return detail;
}
@@ -64,7 +56,6 @@ public class PromptTemplateServiceImpl implements IPromptTemplateService
public int insertPromptTemplate(PromptTemplate template)
{
normalizeTemplate(template, true);
builtinPromptRegistry.resolve(template.getGenerateType());
if (mapper.selectPromptTemplateByCode(template.getPromptCode()) != null)
{
throw new ServiceException("Prompt code already exists: " + template.getPromptCode());
@@ -148,96 +139,6 @@ public class PromptTemplateServiceImpl implements IPromptTemplateService
return changePublication(promptTemplateId, promptVersionId, operator, true);
}
@Override
@Transactional
public int initializeBuiltinV1(String operator)
{
List<PromptTemplate> templates = mapper.selectPromptTemplateList(new PromptTemplate());
if (templates == null || templates.isEmpty())
{
return 0;
}
int initialized = 0;
String initializedBy = StringUtils.defaultIfEmpty(operator, "system");
for (PromptTemplate item : templates)
{
PromptTemplate template = requireLockedTemplate(item.getPromptTemplateId());
int maxVersion = safe(mapper.selectMaxVersionNo(template.getPromptTemplateId()));
if (maxVersion > 0)
{
List<PromptVersion> versions = mapper.selectPromptVersions(
template.getPromptTemplateId());
if (isRepairableInitializedV1(versions))
{
PromptVersion version = versions.get(0);
applyBuiltinV1(template, version, initializedBy);
version.setRemark(BUSINESS_V1_REMARK);
if (mapper.updateInitializedV1(version) != 1)
{
throw new ServiceException("Unable to repair initialized Prompt V1: "
+ template.getPromptCode());
}
initialized++;
}
continue;
}
PromptVersion version = new PromptVersion();
version.setPromptTemplateId(template.getPromptTemplateId());
version.setVersionNo(1);
version.setVersion("V1");
version.setStatus(PromptVersion.STATUS_DRAFT);
applyBuiltinV1(template, version, initializedBy);
version.setRemark(BUSINESS_V1_REMARK);
if (mapper.insertPromptVersion(version) != 1 || version.getPromptVersionId() == null)
{
throw new ServiceException("Unable to initialize Prompt V1: "
+ template.getPromptCode());
}
if (mapper.publishPromptVersion(version.getPromptVersionId(), initializedBy) != 1
|| mapper.updateCurrentVersion(template.getPromptTemplateId(),
version.getPromptVersionId(), initializedBy) != 1)
{
throw new ServiceException("Unable to publish initialized Prompt V1: "
+ template.getPromptCode());
}
initialized++;
}
return initialized;
}
private void applyBuiltinV1(PromptTemplate template, PromptVersion version,
String initializedBy)
{
PromptRelease builtin = builtinPromptRegistry.resolve(template.getGenerateType());
gatewayRouter.resolve(builtin.getProviderCode());
version.setSystemPrompt(builtinPromptRegistry.managementSystemPrompt(
template.getGenerateType()));
version.setUserPromptContract(builtin.getUserPromptContract());
version.setUserPromptTemplate(builtinPromptRegistry.managementUserPromptTemplate(
template.getGenerateType()));
version.setProviderCode(builtin.getProviderCode());
version.setModel(StringUtils.defaultString(builtin.getModel()));
version.setPipelineReleaseCode(builtin.getPipelineReleaseCode());
version.setCreateBy(initializedBy);
version.setContentHash(fingerprintService.fingerprint(template.getGenerateType(),
template.getPromptCode(), version.getVersion(), version.getSystemPrompt(),
version.getUserPromptContract(), version.getUserPromptTemplate()));
}
private boolean isRepairableInitializedV1(List<PromptVersion> versions)
{
if (versions == null || versions.size() != 1)
{
return false;
}
PromptVersion version = versions.get(0);
return Integer.valueOf(1).equals(version.getVersionNo())
&& "V1".equals(version.getVersion())
&& StringUtils.defaultString(version.getUserPromptContract()).startsWith("legacy-builder:")
&& (INITIAL_V1_REMARK.equals(version.getRemark())
|| BUSINESS_V1_REMARK.equals(version.getRemark()));
}
private int changePublication(Long promptTemplateId, Long promptVersionId,
String operator, boolean rollback)
{
@@ -257,13 +158,6 @@ public class PromptTemplateServiceImpl implements IPromptTemplateService
throw new ServiceException("Only a draft Prompt version can be published");
}
verifyContentHash(template, version);
PromptRelease supportedPrompt = builtinPromptRegistry.resolve(template.getGenerateType());
if (!StringUtils.equals(supportedPrompt.getUserPromptContract(),
version.getUserPromptContract()))
{
throw new ServiceException("User Prompt contract is not implemented by this application: "
+ version.getUserPromptContract());
}
gatewayRouter.resolve(version.getProviderCode());
mapper.retirePublishedVersions(promptTemplateId, promptVersionId);
if (mapper.publishPromptVersion(promptVersionId, StringUtils.defaultString(operator)) != 1)

View File

@@ -186,10 +186,11 @@ public class AiGenerateServiceImpl implements IAiGenerateService
String aiContent = chatWithUsage(userId, projectId, "flow_config", prompt);
modelAttempts.add(aiContent);
SimpleFlowConfig config;
AppBlueprintDesign reconciledBlueprint;
try
{
config = parseFlowConfigResponse(aiContent);
validateGeneratedFlowConfig(project, request, config);
reconciledBlueprint = validateGeneratedFlowConfig(project, request, config);
}
catch (RuntimeException firstFailure)
{
@@ -197,9 +198,9 @@ public class AiGenerateServiceImpl implements IAiGenerateService
aiContent = chatWithUsage(userId, projectId, "flow_config", prompt);
modelAttempts.add(aiContent);
config = parseFlowConfigResponse(aiContent);
validateGeneratedFlowConfig(project, request, config);
reconciledBlueprint = validateGeneratedFlowConfig(project, request, config);
}
markFlowConfigGenerated(projectId, project.getDraftRevision(), config, request);
markFlowConfigGenerated(projectId, project.getDraftRevision(), config, reconciledBlueprint, request);
generation.setResponsePayload(aiContent);
generation.setSuccess("1");
generation.setElapsedMs(System.currentTimeMillis() - start);
@@ -253,7 +254,7 @@ public class AiGenerateServiceImpl implements IAiGenerateService
flowConfig, flowCompilation, true);
}
persistDatabase(userId, projectId, response);
markDatabaseGenerated(projectId, project.getDraftRevision(), request, flowConfig);
markDatabaseGenerated(projectId, project.getDraftRevision(), request, appBlueprint, flowConfig);
generation.setResponsePayload(aiContent);
generation.setSuccess("1");
generation.setElapsedMs(System.currentTimeMillis() - start);
@@ -920,7 +921,7 @@ public class AiGenerateServiceImpl implements IAiGenerateService
return flowConfigSanitizer().sanitize(config);
}
private void validateGeneratedFlowConfig(FrontProject project, GenerateFlowConfigRequest request,
private AppBlueprintDesign validateGeneratedFlowConfig(FrontProject project, GenerateFlowConfigRequest request,
SimpleFlowConfig config)
{
String blueprintJson = firstNonBlank(request == null ? null : request.getAppBlueprint(),
@@ -934,6 +935,7 @@ public class AiGenerateServiceImpl implements IAiGenerateService
flowConfigContractService().validateDatabase(loadSavedDatabaseDesign(project.getProjectId()),
config, compilation);
}
return blueprint;
}
private FlowConfigSanitizer flowConfigSanitizer()
@@ -946,8 +948,9 @@ public class AiGenerateServiceImpl implements IAiGenerateService
String appBlueprintJson = resolveConfirmedAppBlueprint(project, request);
SimpleFlowConfig flowConfig = readFlowConfig(project,
request == null ? null : request.getFlowConfig());
FlowConfigCompilation compilation = flowConfigContractService().canonicalize(flowConfig,
parseConfirmedAppBlueprint(appBlueprintJson));
AppBlueprintDesign appBlueprint = parseConfirmedAppBlueprint(appBlueprintJson);
FlowConfigCompilation compilation = flowConfigContractService().canonicalize(flowConfig, appBlueprint);
appBlueprintJson = JSON.toJSONString(appBlueprint);
String flowConfigJson = flowConfig == null ? "" : JSON.toJSONString(flowConfig);
String businessLoopPlanJson = JSON.toJSONString(compilation.getLoopPlan());
Map<String, Object> context = new LinkedHashMap<String, Object>();
@@ -4140,7 +4143,7 @@ public class AiGenerateServiceImpl implements IAiGenerateService
}
private void markDatabaseGenerated(Long projectId, Long draftRevision, GenerateDatabaseRequest request,
SimpleFlowConfig flowConfig)
AppBlueprintDesign blueprint, SimpleFlowConfig flowConfig)
{
FrontProject project = new FrontProject();
project.setProjectId(projectId);
@@ -4150,6 +4153,7 @@ public class AiGenerateServiceImpl implements IAiGenerateService
project.setRequirementKeyword(request == null ? null : request.getExtraRequirements());
project.setProjectDesc(request == null ? null : request.getProjectDesc());
project.setIndustryTemplate(request == null ? null : request.getIndustryTemplate());
if (blueprint != null) project.setAppBlueprint(JSON.toJSONString(blueprint));
if (flowConfig != null) project.setFlowConfig(JSON.toJSONString(flowConfig));
applyStylePreset(project, request == null ? null : request.getStylePreset());
if (frontProjectMapper.updateFrontProjectIfRevision(project) == 0)
@@ -4160,13 +4164,14 @@ public class AiGenerateServiceImpl implements IAiGenerateService
}
private void markFlowConfigGenerated(Long projectId, Long draftRevision, SimpleFlowConfig config,
GenerateFlowConfigRequest request)
AppBlueprintDesign blueprint, GenerateFlowConfigRequest request)
{
FrontProject project = new FrontProject();
project.setProjectId(projectId);
project.setDraftRevision(draftRevision);
project.setPreviewStatus("0");
project.setFlowConfig(JSON.toJSONString(config));
project.setAppBlueprint(JSON.toJSONString(blueprint));
project.setRequirementKeyword(request == null ? null : request.getExtraRequirements());
project.setProjectDesc(request == null ? null : request.getProjectDesc());
project.setIndustryTemplate(request == null ? null : request.getIndustryTemplate());

View File

@@ -24,22 +24,34 @@ public class FlowConfigContractService
private final FlowConfigCompiler flowCompiler;
private final FlowConfigDatabaseContractValidator databaseValidator;
private final BusinessLoopCoverageValidator coverageValidator;
private final FlowConfigPageBindingReconciler pageBindingReconciler;
public FlowConfigContractService()
{
this(new FlowConfigValidator(), new FlowConfigCompiler(),
new FlowConfigDatabaseContractValidator(), new BusinessLoopCoverageValidator());
new FlowConfigDatabaseContractValidator(), new BusinessLoopCoverageValidator(),
new FlowConfigPageBindingReconciler());
}
public FlowConfigContractService(FlowConfigValidator flowValidator, FlowConfigCompiler flowCompiler,
FlowConfigDatabaseContractValidator databaseValidator,
BusinessLoopCoverageValidator coverageValidator)
{
this(flowValidator, flowCompiler, databaseValidator, coverageValidator,
new FlowConfigPageBindingReconciler());
}
@Autowired
public FlowConfigContractService(FlowConfigValidator flowValidator, FlowConfigCompiler flowCompiler,
FlowConfigDatabaseContractValidator databaseValidator,
BusinessLoopCoverageValidator coverageValidator)
BusinessLoopCoverageValidator coverageValidator,
FlowConfigPageBindingReconciler pageBindingReconciler)
{
this.flowValidator = flowValidator;
this.flowCompiler = flowCompiler;
this.databaseValidator = databaseValidator;
this.coverageValidator = coverageValidator;
this.pageBindingReconciler = pageBindingReconciler;
}
public FlowConfigCompilation compile(SimpleFlowConfig config)
@@ -58,8 +70,9 @@ public class FlowConfigContractService
first.getLoopPlan(), blueprint);
if (aliases.isEmpty())
{
boolean bindingChanged = pageBindingReconciler.reconcile(config, blueprint);
validatePageBindings(config, blueprint);
return first;
return bindingChanged ? compile(config) : first;
}
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config))
@@ -83,6 +96,7 @@ public class FlowConfigContractService
if (metric != null) metric.setTable(rename(metric.getTable(), aliases));
}
}
pageBindingReconciler.reconcile(config, blueprint);
validatePageBindings(config, blueprint);
return compile(config);
}
@@ -156,19 +170,25 @@ public class FlowConfigContractService
private void validatePageBindings(SimpleFlowConfig config, AppBlueprintDesign blueprint)
{
Map<String, String> pages = new java.util.LinkedHashMap<String, String>();
Map<String, PageTarget> pages = new java.util.LinkedHashMap<String, PageTarget>();
if (blueprint.getFrontendPages() != null)
{
for (com.ruoyi.generator.domain.front.dto.AppPageDesign page : blueprint.getFrontendPages())
{
if (page != null && StringUtils.isNotBlank(page.getCode())) pages.put(page.getCode(), page.getTableName());
if (page != null && StringUtils.isNotBlank(page.getCode()))
{
pages.put(page.getCode(), new PageTarget(page.getTableName(), page.getPageType()));
}
}
}
if (blueprint.getAdminMenus() != null)
{
for (com.ruoyi.generator.domain.front.dto.AppMenuDesign menu : blueprint.getAdminMenus())
{
if (menu != null && StringUtils.isNotBlank(menu.getPageCode())) pages.put(menu.getPageCode(), menu.getTableName());
if (menu != null && StringUtils.isNotBlank(menu.getPageCode()))
{
pages.put(menu.getPageCode(), new PageTarget(menu.getTableName(), menu.getPageType()));
}
}
}
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config))
@@ -183,16 +203,45 @@ public class FlowConfigContractService
}
String ownerTable = StringUtils.defaultIfBlank(action.getOwnerTable(), flow.getMainTable());
String bindingTable = StringUtils.defaultIfBlank(action.getButton().getTableName(), ownerTable);
String pageTable = pages.get(pageCode);
PageTarget page = pages.get(pageCode);
String pageTable = page.tableName;
if (StringUtils.isNotBlank(pageTable) && !StringUtils.equals(pageTable, bindingTable))
{
throw new ServiceException("flow_config button table does not match page: " + pageCode
+ " -> " + bindingTable + ", expected " + pageTable);
}
validateSlotPageType(action, pageCode, action.getButton().getSlot(), page.pageType);
}
}
}
private void validateSlotPageType(SimpleFlowConfig.Action action, String pageCode, String slot,
String pageType)
{
if (StringUtils.isBlank(slot) || StringUtils.isBlank(pageType)) return;
boolean valid = slot.startsWith("detail.") ? "detail".equals(pageType)
: slot.startsWith("form.") ? "form".equals(pageType)
: slot.startsWith("list.") ? StringUtils.equalsAny(pageType, "list", "current_user_list")
: true;
if (!valid)
{
throw new ServiceException("flow_config button slot does not match page type: "
+ action.getCode() + " -> " + pageCode + " (" + pageType + ") / " + slot);
}
}
private static class PageTarget
{
private final String tableName;
private final String pageType;
private PageTarget(String tableName, String pageType)
{
this.tableName = tableName;
this.pageType = pageType;
}
}
private <T> List<T> safe(List<T> items)
{
return items == null ? java.util.Collections.<T>emptyList() : items;

View File

@@ -0,0 +1,163 @@
package com.ruoyi.generator.service.front;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.front.dto.AppBlueprintDesign;
import com.ruoyi.generator.domain.front.dto.AppPageDesign;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig;
import com.ruoyi.generator.factory.flow.FlowConfigSupport;
/**
* Keeps record-scoped business entry actions on a resource detail page.
*
* <p>An initiating action that loads one resource record and creates the main
* business record is different from a transition on an existing business
* record. The former belongs on the resource detail page; the latter may stay
* in a list row.</p>
*/
@Component
public class FlowConfigPageBindingReconciler
{
public boolean reconcile(SimpleFlowConfig config, AppBlueprintDesign blueprint)
{
if (config == null || blueprint == null || blueprint.getFrontendPages() == null) return false;
boolean changed = false;
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config))
{
for (SimpleFlowConfig.Action action : flow.getActions())
{
if (!requiresResourceDetail(flow, action)) continue;
String ownerTable = action.getOwnerTable();
AppPageDesign detailPage = findPage(blueprint, ownerTable, "detail", null);
AppPageDesign sourcePage = findSourceListPage(blueprint, ownerTable,
action.getButton().getPageCode());
if (detailPage == null && sourcePage != null)
{
detailPage = createDetailPage(blueprint, sourcePage, ownerTable);
blueprint.getFrontendPages().add(detailPage);
changed = true;
}
if (detailPage == null) continue;
SimpleFlowConfig.Button button = action.getButton();
if (!StringUtils.equals(button.getPageCode(), detailPage.getCode()))
{
button.setPageCode(detailPage.getCode());
changed = true;
}
if (!StringUtils.equals(button.getTableName(), ownerTable))
{
button.setTableName(ownerTable);
changed = true;
}
if (!"detail.primaryActions".equals(button.getSlot()))
{
button.setSlot("detail.primaryActions");
changed = true;
}
}
}
return changed;
}
boolean requiresResourceDetail(SimpleFlowConfig flow, SimpleFlowConfig.Action action)
{
if (flow == null || action == null || action.getButton() == null) return false;
if (StringUtils.isBlank(action.getOwnerTable())
|| StringUtils.equals(action.getOwnerTable(), flow.getMainTable())) return false;
if (StringUtils.isNotBlank(action.getFrom()) || StringUtils.isNotBlank(action.getTo())) return false;
if (isAdmin(action.getActor()) || isAdmin(action.getButton().getScope())) return false;
for (SimpleFlowConfig.Effect effect : action.getEffects())
{
if (effect != null && "create_record".equals(effect.getType())
&& StringUtils.equals(flow.getMainTable(), effect.getTable())) return true;
}
return false;
}
private AppPageDesign findSourceListPage(AppBlueprintDesign blueprint, String tableName, String pageCode)
{
AppPageDesign bound = findPage(blueprint, tableName, "list", pageCode);
return bound == null ? findPage(blueprint, tableName, "list", null) : bound;
}
private AppPageDesign findPage(AppBlueprintDesign blueprint, String tableName, String pageType,
String preferredCode)
{
AppPageDesign fallback = null;
for (AppPageDesign page : blueprint.getFrontendPages())
{
if (page == null || !StringUtils.equals(tableName, page.getTableName())
|| !StringUtils.equals(pageType, page.getPageType())) continue;
if (StringUtils.isNotBlank(preferredCode) && StringUtils.equals(preferredCode, page.getCode()))
{
return page;
}
if (fallback == null) fallback = page;
}
return fallback;
}
private AppPageDesign createDetailPage(AppBlueprintDesign blueprint, AppPageDesign sourcePage,
String tableName)
{
AppPageDesign detail = new AppPageDesign();
detail.setCode(uniquePageCode(blueprint, detailPageCode(tableName)));
detail.setName(detailPageName(sourcePage.getName()));
detail.setMenuCode(sourcePage.getMenuCode());
detail.setParentPageCode(sourcePage.getCode());
detail.setPath(detailPath(sourcePage.getPath(), tableName));
detail.setPageType("detail");
detail.setTableName(tableName);
return detail;
}
private String uniquePageCode(AppBlueprintDesign blueprint, String baseCode)
{
Set<String> used = new HashSet<String>();
for (AppPageDesign page : blueprint.getFrontendPages())
{
if (page != null && StringUtils.isNotBlank(page.getCode())) used.add(page.getCode());
}
if (!used.contains(baseCode)) return baseCode;
int suffix = 2;
while (used.contains(baseCode + "_" + suffix)) suffix++;
return baseCode + "_" + suffix;
}
private String detailPageCode(String tableName)
{
String base = StringUtils.defaultString(tableName).toLowerCase(Locale.ENGLISH)
.replaceAll("(_info|_records|_record|_table|_data|_list)$", "");
if (StringUtils.isBlank(base)) base = "resource";
return base + "_detail_page";
}
private String detailPageName(String sourceName)
{
String name = StringUtils.defaultIfBlank(sourceName, "资源").trim();
String replaced = name.replaceFirst("(目录|列表)$", "详情")
.replaceFirst("(?i)(catalog|list)$", "Detail");
return StringUtils.equals(replaced, name) ? name + "详情" : replaced;
}
private String detailPath(String sourcePath, String tableName)
{
String path = StringUtils.defaultString(sourcePath).trim().replaceAll("/+$", "");
if (StringUtils.isBlank(path))
{
String resource = detailPageCode(tableName).replaceFirst("_detail_page$", "").replace('_', '-');
path = "/" + resource;
}
return path + "/:id";
}
private boolean isAdmin(String value)
{
String normalized = StringUtils.defaultString(value).trim().toLowerCase(Locale.ENGLISH);
return "admin".equals(normalized) || "admin_frontend".equals(normalized);
}
}

View File

@@ -31,7 +31,6 @@ import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.config.DeepSeekProperties;
import com.ruoyi.generator.factory.ai.prompt.BuiltinPromptRegistry;
@Component
public class HttpDeepSeekClient implements IDeepSeekClient
@@ -64,19 +63,11 @@ public class HttpDeepSeekClient implements IDeepSeekClient
}
};
private static final String DEFAULT_SYSTEM_PROMPT = BuiltinPromptRegistry.DEFAULT_SYSTEM_PROMPT;
@Autowired
private DeepSeekProperties deepSeekProperties;
@Autowired(required = false)
private AiUsageRecorder aiUsageRecorder;
@Override
public String chat(String prompt)
{
return chat(DEFAULT_SYSTEM_PROMPT, prompt);
}
@Override
public String chat(String systemPrompt, String prompt)
{
@@ -86,6 +77,7 @@ public class HttpDeepSeekClient implements IDeepSeekClient
@Override
public String chatModel(String model, String systemPrompt, String prompt)
{
requireManagedSystemPrompt(systemPrompt);
if (StringUtils.isBlank(deepSeekProperties.getApiKey()))
{
throw new ServiceException("DeepSeek API Key 未配置");
@@ -100,12 +92,6 @@ public class HttpDeepSeekClient implements IDeepSeekClient
return parseContent(response.getBody(), model);
}
@Override
public void chatStream(String prompt, final StreamHandler handler)
{
chatStream(DEFAULT_SYSTEM_PROMPT, prompt, handler);
}
@Override
public void chatStream(String systemPrompt, String prompt, final StreamHandler handler)
{
@@ -116,6 +102,7 @@ public class HttpDeepSeekClient implements IDeepSeekClient
public void chatStreamModel(String model, String systemPrompt, String prompt,
final StreamHandler handler)
{
requireManagedSystemPrompt(systemPrompt);
if (StringUtils.isBlank(deepSeekProperties.getApiKey()))
{
throw new ServiceException("DeepSeek API Key 未配置");
@@ -140,11 +127,6 @@ public class HttpDeepSeekClient implements IDeepSeekClient
});
}
private JSONObject requestBody(String prompt, boolean stream)
{
return requestBody(DEFAULT_SYSTEM_PROMPT, prompt, stream);
}
private JSONObject requestBody(String systemPrompt, String prompt, boolean stream)
{
return requestBody(deepSeekProperties.getModel(), systemPrompt, prompt, stream);
@@ -152,10 +134,11 @@ public class HttpDeepSeekClient implements IDeepSeekClient
private JSONObject requestBody(String model, String systemPrompt, String prompt, boolean stream)
{
requireManagedSystemPrompt(systemPrompt);
JSONObject request = new JSONObject();
request.put("model", StringUtils.defaultIfEmpty(model, deepSeekProperties.getModel()));
JSONArray messages = new JSONArray();
messages.add(message("system", StringUtils.defaultIfEmpty(systemPrompt, DEFAULT_SYSTEM_PROMPT)));
messages.add(message("system", systemPrompt));
messages.add(message("user", prompt));
request.put("messages", messages);
request.put("temperature", 0.2);
@@ -169,6 +152,14 @@ public class HttpDeepSeekClient implements IDeepSeekClient
return request;
}
private void requireManagedSystemPrompt(String systemPrompt)
{
if (StringUtils.isBlank(systemPrompt))
{
throw new ServiceException("System Prompt must come from a published managed Prompt version");
}
}
private JSONObject message(String role, String content)
{
JSONObject message = new JSONObject();

View File

@@ -9,14 +9,10 @@ public interface IDeepSeekClient
void onContent(String content) throws IOException;
}
String chat(String prompt);
String chat(String systemPrompt, String prompt);
String chatModel(String model, String systemPrompt, String prompt);
void chatStream(String prompt, StreamHandler handler);
void chatStream(String systemPrompt, String prompt, StreamHandler handler);
void chatStreamModel(String model, String systemPrompt, String prompt, StreamHandler handler);

View File

@@ -8,6 +8,7 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.front.AiGenerationTask;
import com.ruoyi.generator.domain.front.FrontProject;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.AppBlueprintDesign;
import com.ruoyi.generator.domain.front.dto.DatabaseDesignResponse;
import com.ruoyi.generator.domain.front.dto.OneClickProjectGenerationException;
import com.ruoyi.generator.domain.front.dto.OneClickProjectGenerationReport;
@@ -38,6 +39,8 @@ public class OneClickProjectGenerationServiceImpl implements IOneClickProjectGen
private FlowConfigDatabaseContractValidator flowConfigDatabaseContractValidator;
@Autowired(required = false)
private BusinessRequirementCoverageValidator businessRequirementCoverageValidator;
@Autowired(required = false)
private FlowConfigPageBindingReconciler pageBindingReconciler;
@Override
public OneClickProjectGenerationResult generate(AiGenerationTask task, AiGenerationTaskCreateRequest request)
@@ -106,6 +109,11 @@ public class OneClickProjectGenerationServiceImpl implements IOneClickProjectGen
{
throw new ServiceException("Flow config is missing in the design checkpoint");
}
AppBlueprintDesign blueprint = JSON.parseObject(context.getAppBlueprintJson(), AppBlueprintDesign.class);
if (pageBindingReconciler().reconcile(context.getFlowConfig(), blueprint))
{
throw new ServiceException("Flow config resource actions require regenerated detail pages");
}
requirementCoverageValidator().validate(requirement(context.getRequest()), context.getFlowConfig());
return true;
}
@@ -204,6 +212,11 @@ public class OneClickProjectGenerationServiceImpl implements IOneClickProjectGen
? new BusinessRequirementCoverageValidator() : businessRequirementCoverageValidator;
}
private FlowConfigPageBindingReconciler pageBindingReconciler()
{
return pageBindingReconciler == null ? new FlowConfigPageBindingReconciler() : pageBindingReconciler;
}
private String requirement(AiGenerationTaskCreateRequest request)
{
if (request == null) return "";

View File

@@ -1 +0,0 @@
你是应用蓝图架构师。只输出符合用户消息结构的 JSON规划业务角色、前台菜单与页面、后台菜单和运营看板。保持 pageCode、tableName、角色数据范围和登录要求前后一致只描述静态蓝图不生成数据库、权限表、菜单表或代码。角色名称使用简体中文技术标识使用稳定的小写 snake_case。

View File

@@ -1,22 +0,0 @@
根据项目输入 JSON 生成应用蓝图,只返回 JSON不要 Markdown 围栏或解释。
输出结构:{"roles":[{"code":"reader","name":"读者","description":""}],"frontendMenus":[{"code":"book_catalog","name":"图书目录","path":"/books","pageCode":"book_catalog_page","requiresLogin":false,"visibleRoles":[],"dataScope":"PUBLIC"}],"frontendPages":[{"code":"book_catalog_page","name":"图书目录","menuCode":"book_catalog","path":"/books","pageType":"list","tableName":"book_info"}],"adminMenus":[{"code":"book_admin","name":"图书管理","path":"/admin/books","pageCode":"book_admin_page","tableName":"book_info","pageType":"list","requiresLogin":true,"visibleRoles":["admin"],"dataScope":"ROLE"}],"adminHomeDashboard":{"title":"运营总览","description":"核心业务数据","metrics":[],"charts":[]}}。
蓝图描述目标生成项目,而不是当前 EasyCode/RuoYi 平台。不要生成数据库表、菜单表、角色表、权限表或代码。
role.name 使用简体中文role.code 使用小写 snake_case。只有 role.code=admin 是系统管理员;名称含“管理员”或“后台”的业务角色仍保持独立。
role.name must be Simplified Chinese. Only role.code=admin is the system administrator.
每个菜单必须包含 requiresLogin、visibleRoles、dataScopedataScope 只能是 PUBLIC、CURRENT_USER 或 ROLE。
adminMenus 必须包含 pageCode、tableName、pageType个人资料使用 form普通管理使用 list。
adminMenus must include pageCode, tableName, and pageType.
frontendMenus 顶层菜单应指向 pageCode详情页作为子页面不创建顶层详情菜单。
不要为轮播图、banner、公告滚动条等首页装饰创建顶层菜单可放在首页业务块或作为后台维护数据。
frontendPages.pageType 只能是 list、current_user_list、detail、formtableName 必须是页面展示的业务表。
同一业务对象只使用一个稳定 tableName后续数据库、闭环、页面、看板、动作必须复用不能使用同义表名。
adminHomeDashboard 尽量包含 4 个业务指标和至少 2 张图表趋势图与分布图所有表字段必须可由后续数据库步骤创建。semantic 只能使用 money、order、user、task、warning、inventory、content、default。
adminHomeDashboard.metrics 每项结构为 {"code":"total_books","title":"总图书数","tableName":"book_info","aggregate":"count","field":"book_id","semantic":"inventory"}。
adminHomeDashboard.charts 每项结构为 {"code":"borrow_trend","title":"借阅趋势","type":"line","tableName":"borrow_record","dimension":{"field":"borrow_date","timeUnit":"day"},"metrics":[{"field":"borrow_id","aggregate":"count","alias":"borrowCount","label":"借阅量"}],"semantic":"order"}。dimension 必须是对象metrics 必须是数组;不要输出旧版 xField、yField、metric 或字符串 dimension。
每个非 admin 角色使用表名等于 role.code 的独立账号表,不得创建共享 user、user_info、member 或 account 表。
Never invent a shared user, user_info, member, or account table for role accounts.
需要个人资料页时,为每个非 admin 角色创建独立 form 页面和菜单tableName 绑定 role.codepageCode/path 带角色前缀visibleRoles 只包含该角色。
Create one form page and one menu per non-admin role when profile pages are needed.
个人订单、借阅、资料、预约、支付等页面必须 requiresLogin=true 且 dataScope=CURRENT_USER。
项目输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是资深 Java/Vue 毕设项目代码解读专家。只依据用户消息中提供的源码、文件清单和项目元信息生成正式 Markdown 报告,引用真实类名、方法名、组件、表和字段;无法确认的内容必须明确说明,不得臆造模块或流程。报告应覆盖项目概述、技术栈、架构模块、核心业务、数据库设计和答辩准备。

View File

@@ -1,12 +0,0 @@
根据输入 JSON 中的精选核心源码和项目元信息生成正式 Markdown 代码解读报告,不要代码围栏或 JSON。
只能依据已展开源码和文件清单,不要编造未出现的模块、接口、表或流程;无法确认时明确说明。引用真实类名、方法名、组件名、表名和字段名。
无法从精选源码确认的内容必须明确标注“无法从精选源码确认”。
必须按顺序包含:
## 1. 项目概述与技术栈
## 2. 系统架构与核心模块
## 3. 核心业务流程
## 4. 数据库设计
## 5. 答辩准备
若 input.mode=continue在保留主题和章节结构的前提下根据 previousMarkdown 补充、修正和续写,避免重复无变化的长段。
输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是 MySQL 业务数据库设计专家。只输出符合用户消息结构的 JSON根据已确认的应用蓝图和业务闭环生成可直接落地的表、字段、主外键、字典和表操作策略。字段必须业务化且类型合法所有蓝图与闭环引用必须有真实表字段承载不得生成菜单、权限关系、代码或业务动作。flowConfig.actions[].audit 中非空的 userField 和 timeField 必须是 mainTable 的真实字段userField 使用 bigint(20)timeField 使用 datetime。

View File

@@ -1,30 +0,0 @@
根据项目输入 JSON 生成 MySQL 数据库设计 JSON只返回 JSON不要 Markdown 围栏或解释。
Confirmed app blueprint JSON and Confirmed business loop plan JSON are supplied in the input.
Database generation constraints from the business loop plan must be enforced.
Every confirmedBusinessLoopPlan.metrics tableName, field, and filters.field must exist in the generated schema. Numeric sum/avg/min/max metric fields must use a compatible numeric column type. Status-filter metric values must remain compatible with the matching stateMachines.states.
If input.mode is correction, repair input.originalResponse according to input.validationError and return the complete corrected database JSON. Do not drop unrelated valid tables or fields.
输出结构:{"tables":[{"tableName":"car_info","tableComment":"车辆信息表","adminMenuName":"车辆管理","operations":{"add":true,"edit":true,"remove":true,"removeType":"physical","reason":""},"columns":[{"columnName":"id","columnLabel":"ID","columnComment":"主键ID","columnType":"bigint(20)","isPk":"1","isIncrement":"1","isRequired":"1"}]}]}。
不要使用 field_1 等占位字段;每个表必须包含非空 columns以及 adminMenuName 和 operations.add/edit/remove/removeType/reason。
adminMenuName 必须来自本表 tableComment/tableName不得复用其他表或宽泛模块名称。
adminMenuName must come from that table's own tableComment or tableName.
字段数组名称只能是 columns不能使用 fields、fieldDesign、fieldList、columnList。
Every table must include a non-empty columns array; use columns exactly.
每个字段必须包含 columnName、columnLabel、columnComment、columnType、isPk、isIncrement、isRequired。columnLabel 是短 UI 标签columnComment 是完整数据库说明。
可提供 javaType、htmlTypehtmlType 只能是 input、textarea、select、radio、checkbox、datetime、imageUpload、fileUpload、editor。
select/radio/checkbox 应提供 dictType 和 dictOptions。关联选择使用 remote:target_table:value_field:label_field且目标表和值/标签字段必须都存在于本次 tables JSON。
非主键外键字段应在目标表存在时使用 remote select无目标表的分类、类型、状态使用本地 dictOptions 或普通输入。
普通主数据表除唯一主键外至少包含 4 个业务字段。
订单、支付、借还、审批、流程、任务、库存记录等动作记录表使用仅新增;关系、审计、日志、历史表使用只读;普通主数据表使用完整 CRUD。
不要生成 businessActions、业务动作、工作流、代码、菜单表、user_role、角色权限表或权限表。
Do not generate menu tables, user_role tables, role-permission tables, or permission tables.
表名字段名只使用小写字母、数字、下划线;每表必须且只能有一个主键。字段类型只能使用 bigint(20)、int(11)、varchar(n)、char(n)、decimal(p,s)、datetime、date、text、longtext。
为 confirmedAppBlueprint.roles 中每个角色创建独立账号表admin 使用 admin其他角色使用 role.code。不得使用共享用户表或 role_id/role_code/user_role。
角色账号表包含 `<role_code>_id` 自增主键、username、password_hash、nickname、status、create_time。admin 只能登录后台,非 admin 角色登录前台。
生成 frontendPages.tableName 引用的全部表;个人资料页必须绑定对应非 admin 角色账号表。requiresLogin=true 且 dataScope=CURRENT_USER 的业务数据通常包含 user_id。
canonicalTableRegistry 中的名称是最终规范表名。confirmedBusinessLoopPlan 若出现与其指向同一业务对象的同义表名,必须使用 canonicalTableRegistry 中的名称,不得同时生成两张同义表。
根据 confirmedBusinessLoopPlan 创建 coreObjects.tableName、actions.ownerTable补齐 stateMachines.statusField、quantityRules.quantityField 以及 recordRules 和动作前置条件需要的字段。状态值不得超出 stateMachines.states。
严格实现 confirmedBusinessLoopPlan.businessPoliciesTIME_LIMIT 的 startField、dueField 使用 datetimeOVERDUE_FEE 的 dueField 使用 datetime、daysTargetField 使用 int(11)、targetField 使用 decimal(10,2)QUANTITY_LIMIT 的 targetField 使用 int(11) 且默认值为 0。所有政策字段必须位于政策指定的 tableName禁止只把期限或公式写进 columnComment 而不创建字段。
Do not invent status values outside stateMachines.states.
严格遵守 input.limits.maxTables 和 maxColumnsPerTable。
项目输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是数据库变更同步专家。根据已确认的数据库变更分析受影响的蓝图、业务闭环、页面、动作和生成产物,保持表字段引用及业务语义一致。只提出可验证的同步结果,不虚构不存在的表、字段或模块。

View File

@@ -1,3 +0,0 @@
根据数据库变更和项目上下文同步受影响的生成结果。严格遵守输入中的阶段契约。
输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是企业管理系统生成助手。严格遵守用户消息中的任务、输出格式和语言要求。

View File

@@ -1 +0,0 @@
你是软件设计图专家。只输出符合用户消息结构的 JSON同时提供有效 Mermaid、节点和连线数据。根据指定图类型生成简体中文、结构清晰且规模适中的流程图、用例图、时序图、活动图、状态图、数据流图或架构图不得虚构与当前项目无关的系统。

View File

@@ -1,6 +0,0 @@
根据输入 JSON 生成软件设计图,只返回 JSON不要 Markdown 围栏或解释。
输出结构:{"diagramType":"flowchart","title":"...","description":"...","mermaid":"flowchart TD...","markdown":"...","nodes":[{"id":"start","label":"开始","type":"start","lane":"用户","description":""}],"edges":[{"source":"start","target":"next","label":""}]}。
支持 flowchart、use_case、sequence、activity、state、data_flow、architecture。用例图使用 actor/use_case/system_boundary流程、活动、状态图使用过程和判断节点架构图使用 frontend/backend/database/deployment 节点。
标签使用简体中文,节点 id 只使用小写字母、数字、下划线。保持 6 到 14 个节点、6 到 18 条边,不虚构无关系统。
输入 JSON
{{input}}

View File

@@ -1,55 +0,0 @@
你是业务流程配置设计器。只输出一个合法 JSON 对象,不输出 Markdown、解释或 SQL。
目标:用最小 flow_config 描述业务发起、状态流转、显式 Java 业务动作、多表数量联动、按钮和统计。不要输出普通 CRUD不要输出通用规则 DSL。创建主业务记录的“借书/预约/申请/下单”等发起动作属于业务闭环,必须输出。项目存在预约、借阅等多个独立状态对象时,必须分别放入 flows禁止只选择其中一个局部流程。
固定结构:
{
"version":"1.1",
"code":"library_circulation",
"name":"图书流通",
"flows":[{
"code":"reservation_flow","name":"图书预约","mainTable":"reservation","statusField":"status",
"notFoundMessage":"预约记录不存在",
"states":[{"code":"pending","label":"待处理"},{"code":"cancelled","label":"已取消"}],
"actions":[{
"code":"reserve_book","name":"预约","actor":"reader","ownerTable":"book_info","requestFields":["id"],
"method":"POST","path":"/book-info/reserve","successMessage":"预约成功",
"effects":[{"type":"create_record","table":"reservation","values":{"book_id":"record.id","reader_id":"currentUserId","status":"pending","reserve_date":"now","create_time":"now"}}],
"button":{"scope":"reader","pageCode":"book_detail_page","tableName":"book_info","slot":"detail.primaryActions","type":"primary","confirm":"确认预约吗?","order":10}
},{
"code":"cancel_reservation","name":"取消预约","from":"pending","to":"cancelled","actor":"reader",
"ownerTable":"reservation","requestFields":["id"],"method":"POST","path":"/reservation/cancel",
"button":{"scope":"reader","pageCode":"my_reservations_page","tableName":"reservation","slot":"list.rowActions","type":"danger","confirm":"确认取消预约吗?","order":10}
}],
"metrics":[]
},{
"code":"borrowing_flow","name":"图书借阅","mainTable":"borrowing","statusField":"status",
"notFoundMessage":"借阅记录不存在",
"states":[{"code":"borrowing","label":"借阅中"},{"code":"returned","label":"已归还"}],
"actions":[{
"code":"borrow_book","name":"借书","actor":"reader","ownerTable":"book_info","requestFields":["id"],
"method":"POST","path":"/book-info/borrow","successMessage":"借书成功",
"effects":[{"type":"create_record","table":"borrowing","values":{"book_id":"record.id","reader_id":"currentUserId","status":"borrowing","borrow_date":"now","create_time":"now"}},
{"type":"decrease","table":"book_info","relationField":"id","field":"stock","amount":"1","min":"0","errorMessage":"图书库存不足"}],
"button":{"scope":"reader","pageCode":"book_detail_page","tableName":"book_info","slot":"detail.primaryActions","type":"success","confirm":"确认借书吗?","order":20}
},{
"code":"return_book","name":"还书","from":"borrowing","to":"returned","actor":"reader","ownerTable":"borrowing","requestFields":["id"],
"method":"POST","path":"/borrowing/return","effects":[{"type":"increase","table":"book_info","relationField":"book_id","field":"stock","amount":"1"}],
"button":{"scope":"reader","pageCode":"my_borrowings_page","tableName":"borrowing","slot":"list.rowActions","type":"primary","confirm":"确认还书吗?","order":10}
}],
"metrics":[{"code":"borrowing_count","name":"借阅中数量","table":"borrowing","aggregate":"count","field":"id","status":"borrowing","unit":"条"}]
}]
}
约束:
1. 根对象固定使用 version=1.1 和 flows 数组;每个独立状态主表对应一个 flow。code、表名、字段名全部使用小写 snake_case状态 code 使用稳定英文编码label 使用中文。
2. 每个 action 必须给 ownerTablerequestFields 只列页面传入字段,通常为 ownerTable 主键 idcurrentUserId 不能作为前端可传字段。
3. 除发起型 action 外,每个 action 必须有 from 和 to二者存在于 states 中且不相同ownerTable 必须为 mainTable。
4. 创建主业务记录的发起型 action 必须省略 from/to并 create_record 到 mainTablevalues 必须给 statusField 写入 states 中的初始状态。借书、预约、申请、报名、下单、提交工单等需求不得误判为普通 CRUD 而省略。
5. effects.type 只能是 decrease、increase、update_related、create_record。
6. decrease/increase 必须给 table、relationField、field、amountdecrease 还要给 min。
7. update_related/create_record 使用 values值可为 currentUserId、now、record.字段名或常量。record 表示 ownerTable 当前记录。create_record 必须覆盖目标表全部非自增、无默认值的必填字段create_time、created_at 等创建时间字段必须写为 now。
8. 审核人等状态动作身份通过 audit.userField 表达;发起型动作的用户和时间通过 create_record.values 的 currentUserId、now 表达,均由后端登录态注入。
9. requiredActionCodes 中的每个动作都必须在 actions 中出现,并绑定到 appBlueprint 已有页面。覆盖需求中的完整正向闭环、业务发起动作和关键回退/拒绝动作,但不要为无业务语义的普通 CRUD 创建 action。
10. metrics.status 如果填写,必须完全等于 states 中某个 code统计全状态数据或不能确定状态时省略 status。
11. 每个面向用户或管理员的显式动作必须有 button。button.pageCode 必须引用 appBlueprint 中已有页面button.tableName 必须是该页面绑定表,省略时等于 ownerTablebutton.slot 只能是 list.toolbarActions、list.rowActions、detail.primaryActions、detail.secondaryActions、form.footerActions。详情页主操作使用 detail.primaryActions列表记录操作使用 list.rowActions禁止使用 page.actions 等自定义值。

View File

@@ -1,2 +0,0 @@
请根据以下 JSON 上下文生成 flow_config。appBlueprint 是已确认的页面、角色和菜单范围,必须与它保持一致:
{{input}}

View File

@@ -1 +0,0 @@
你是一键项目生成流水线编排专家。按照应用蓝图、业务闭环、数据库和业务动作的固定阶段顺序工作,保证各阶段 Prompt 身份、输入输出和引用关系可追踪、可恢复、可复现。不跨阶段生成不属于当前阶段的内容。

View File

@@ -1,3 +0,0 @@
按照一键生成阶段契约处理项目输入,当前阶段只输出该阶段要求的内容。
输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是中文软件工程本科毕业论文写作助手。只输出论文正文 Markdown首个非空字符必须是 #不要确认语、解释、JSON 或代码围栏。必须围绕当前项目上下文写作,不套用无关行业示例。

View File

@@ -1,13 +0,0 @@
根据输入 JSON 撰写软件工程本科论文 Markdown 初稿。
Return Markdown only. Do not include acknowledgements, confirmations, explanations, or preamble before the thesis content. The first non-blank character of the response must be #.
首个非空字符必须是 #,正文前不要确认、解释或前言。使用简体中文,篇幅参考 input.targetWords以章节连贯性优先。
严格按以下大纲生成,不得跳过或调换:系统设定、第 1 至第 7 章、参考文献、致谢。
必须严格按照以下大纲生成论文初稿:系统设定、第 1 章 绪论、第 2 章 相关技术介绍、第 3 章 系统需求分析、第 4 章 系统设计、第 5 章 系统实现、第 6 章 系统测试、第 7 章 总结与展望、参考文献与致谢。
系统设定应结合项目名称、需求、应用蓝图、闭环和数据库,说明模块、角色、核心表与流程。
第 1 章 绪论;第 2 章 相关技术;第 3 章 系统需求分析;第 4 章 系统设计;第 5 章 系统实现;第 6 章 系统测试;第 7 章 总结与展望;参考文献;致谢。
模块、角色、表和流程必须来自当前项目。除非项目确实包含,否则不得套用图书、读者、借阅等示例。
Use module names, roles, and tables from the current project context. Do not use unrelated sample domain terms.
图表位置使用“[此处插入 XX 图]”占位。不得虚构不可核验引用,明确提醒学生核验参考文献。
合理使用标题、段落和表格;除配置片段外避免代码块。
输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是中文软件工程本科毕业论文导师。只输出用户要求的 JSON不要 Markdown 代码块或解释。

View File

@@ -1,16 +0,0 @@
根据输入 JSON 生成软件工程本科论文提纲,只返回 JSON不要 Markdown 围栏或解释。
输出结构:{"title":"...","abstractDraft":"...","keywords":["Spring Boot","Vue"],"chapters":[{"title":"系统设定","goal":"...","sections":["模块设定","角色设定","数据表设定"]}]}。
使用简体中文,不虚构参考文献;模块、角色、表名必须来自当前项目,不使用无关行业示例。
chapters 必须严格按以下顺序,每项包含 2 到 5 个具体小节:
系统设定:结合项目名称、需求描述、应用蓝图、业务闭环和数据库表,推导系统模块、用户角色、核心数据表与关键业务流程。
第 1 章 绪论:研究背景、研究意义、国内外研究现状、研究内容与论文结构。
第 2 章 相关技术介绍:围绕实际技术栈介绍关键技术。
第 3 章 系统需求分析:可行性、用户角色、功能需求、非功能需求和业务流程。
第 4 章 系统设计:总体架构、功能模块、数据库、接口和安全设计。
第 5 章 系统实现:围绕当前项目实际模块展开。
第 6 章 系统测试:测试环境、方法、功能测试、异常测试、兼容性测试和结论。
第 7 章 总结与展望:成果、不足和后续优化。
参考文献与致谢:提醒学生人工核验参考文献真实性与学校格式。
Target a later draft of about 10000 Chinese characters.
输入 JSON
{{input}}

View File

@@ -1 +0,0 @@
你是资深软件需求分析师。面向本科毕设或课程设计规模在保留用户原始描述的前提下补全真实业务闭环、角色、核心对象和可执行业务规则。规则必须给出明确的时间、数量、金额、状态、公式、上限和异常边界用户未提供关键数值时应给出合理默认值并标注“AI假设”不得使用“按实际情况”“超时后处理”等模糊表述。不要把需求扩张成大型企业平台也不要用增删改查或运维功能代替业务需求。只输出用户消息要求的中文纯文本不要 Markdown、JSON 或解释。

View File

@@ -1,17 +0,0 @@
请根据以下 JSON 项目输入为低代码生成器补全一份可直接驱动代码生成的项目需求。
需求复杂度控制在本科毕设或课程设计级别,功能要完整但不要设计成大型企业平台或运维平台。
currentDescription 是用户原始描述,必须作为事实来源保留其约束;输出补充内容,不要改写或否定用户已经明确的规则。
只返回中文纯文本,不要 Markdown不要 JSON不要解释。格式必须严格如下
业务目标:一句话说明系统解决的问题
角色:用顿号分隔 2 到 4 个用户角色
核心对象:列出流程涉及的主要业务对象和记录
核心流程:按先后顺序写出完整正向流程、逆向动作和补偿动作,不要只写增删改查
业务规则:用分号分隔明确规则;必须覆盖适用的时间期限、起算点、状态判定、数量限制、金额公式、计费单位、取整方式、封顶值、重复操作限制
异常边界:说明库存不足、状态不符、重复提交、取消、超时、重复支付等适用场景
验收场景:至少给出一个包含边界数值和预期结果的完整场景
如果输入没有给出期限、费率、上限等关键数值必须选择合理默认值并在该条前标注“AI假设禁止输出“待定”“按实际情况”“超时后罚款”等不可执行描述。
对于借阅、租赁、预约等有期限的流程,必须明确起算时间、期限、到期判定和逾期处理;存在罚款或费用时必须明确公式、日费率、取整和上限。
其他模块:用顿号分隔 2 到 4 个前台展示或内容维护模块,优先从轮播图、公告、新闻资讯、友情链接、留言反馈、帮助中心、系统简介中选择
不要出现通知提醒、统计报表、数据备份、操作日志、系统监控、定时任务等后台运维或企业管理平台标配模块。
项目输入 JSON
{{input}}

View File

@@ -152,24 +152,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{createBy}, sysdate(), #{remark})
</insert>
<update id="updateInitializedV1">
update factory_prompt_version
set system_prompt = #{systemPrompt},
user_prompt_contract = #{userPromptContract},
user_prompt_template = #{userPromptTemplate},
provider_code = #{providerCode},
model = #{model},
pipeline_release_code = #{pipelineReleaseCode},
content_hash = #{contentHash},
published_at = sysdate(),
remark = #{remark}
where prompt_version_id = #{promptVersionId}
and version_no = 1
and version_label = 'V1'
and user_prompt_contract like 'legacy-builder:%'
and remark in ('由当前代码内置 Prompt 初始化', '由当前代码内置业务 Prompt 初始化')
</update>
<update id="retirePublishedVersions">
update factory_prompt_version
set status = 'RETIRED'

View File

@@ -1,94 +0,0 @@
package com.ruoyi.generator.factory.ai.prompt;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.factory.dsl.ProjectSpecJsonCodec;
import com.ruoyi.generator.factory.generation.CanonicalFingerprintService;
public class BuiltinPromptRegistryTest
{
private final BuiltinPromptRegistry registry = new BuiltinPromptRegistry(
new PromptFingerprintService(
new CanonicalFingerprintService(new ProjectSpecJsonCodec())));
@Test
public void resolvesLatestAndExactPublishedPromptIdentity()
{
PromptRelease latest = registry.resolve("database");
PromptRelease exact = registry.resolve(latest.getPromptCode(), latest.getVersion());
assertEquals("front.database", latest.getPromptCode());
assertEquals("1.0.0", latest.getVersion());
assertEquals(latest.getFingerprint(), exact.getFingerprint());
assertEquals(64, latest.getFingerprint().length());
assertTrue(latest.getUserPromptContract().contains("context-json:database:v1"));
}
@Test
public void paperPromptOwnsItsPublishedSystemInstruction()
{
PromptRelease paper = registry.resolve("paper_draft");
PromptRelease database = registry.resolve("database");
assertTrue(paper.getSystemPrompt().contains("论文正文 Markdown"));
assertFalse(paper.getFingerprint().equals(database.getFingerprint()));
}
@Test
public void eachBusinessTypeOwnsTaskSpecificSystemInstruction()
{
String requirement = registry.managementSystemPrompt("requirement");
String blueprint = registry.managementSystemPrompt("app_blueprint");
String flow = registry.managementSystemPrompt("flow_config");
String database = registry.managementSystemPrompt("database");
assertTrue(requirement.contains("AI假设"));
assertTrue(requirement.contains("时间、数量、金额、状态、公式"));
assertTrue(blueprint.contains("应用蓝图"));
assertTrue(flow.contains("最小 flow_config"));
assertTrue(flow.contains("decrease"));
assertTrue(flow.contains("currentUserId"));
assertTrue(flow.contains("statusField"));
assertTrue(database.contains("MySQL"));
assertTrue(registry.managementUserPromptTemplate("database").contains("QUANTITY_LIMIT"));
assertFalse(requirement.equals(database));
assertFalse(blueprint.equals(flow));
assertTrue(registry.managementUserPromptTemplate("database").contains("数据库设计 JSON"));
assertTrue(registry.managementUserPromptTemplate("database").contains("{{input}}"));
assertFalse(registry.managementUserPromptTemplate("database").equals(
registry.managementUserPromptTemplate("code_analysis")));
}
@Test
public void requirementReleaseOwnsTheCompleteManagedPrompt()
{
PromptRelease requirement = registry.resolve("requirement");
assertEquals(BuiltinPromptRegistry.REQUIREMENT_SYSTEM_PROMPT,
requirement.getSystemPrompt());
assertEquals(BuiltinPromptRegistry.REQUIREMENT_USER_PROMPT_TEMPLATE,
requirement.getUserPromptTemplate());
assertTrue(requirement.getUserPromptTemplate().contains("业务规则"));
assertTrue(requirement.getUserPromptTemplate().contains("验收场景"));
assertTrue(requirement.getUserPromptTemplate().contains("期限、费率、上限"));
assertTrue(requirement.getUserPromptTemplate().contains("{{input}}"));
}
@Test
public void rejectsUnknownPromptRelease()
{
try
{
registry.resolve("front.database", "9.9.9");
fail("Expected ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("Prompt release does not exist"));
}
}
}

View File

@@ -8,7 +8,6 @@ import static org.mockito.Mockito.when;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.config.PromptRegistryProperties;
import com.ruoyi.generator.factory.ai.prompt.model.PromptVersion;
import com.ruoyi.generator.factory.dsl.ProjectSpecJsonCodec;
import com.ruoyi.generator.factory.generation.CanonicalFingerprintService;
@@ -17,9 +16,7 @@ import com.ruoyi.generator.mapper.factory.PromptRegistryMapper;
public class DatabasePromptRegistryTest
{
private PromptRegistryMapper mapper;
private BuiltinPromptRegistry builtin;
private PromptFingerprintService fingerprints;
private PromptRegistryProperties properties;
private DatabasePromptRegistry registry;
@Before
@@ -28,23 +25,12 @@ public class DatabasePromptRegistryTest
mapper = mock(PromptRegistryMapper.class);
fingerprints = new PromptFingerprintService(
new CanonicalFingerprintService(new ProjectSpecJsonCodec()));
builtin = new BuiltinPromptRegistry(fingerprints);
properties = new PromptRegistryProperties();
registry = new DatabasePromptRegistry(mapper, builtin, fingerprints, properties);
}
@Test
public void disabledDatabaseUsesBuiltinRelease()
{
properties.setDatabaseEnabled(false);
assertEquals(builtin.resolve("database").getFingerprint(),
registry.resolve("database").getFingerprint());
registry = new DatabasePromptRegistry(mapper, fingerprints);
}
@Test
public void resolvesCurrentAndExactDatabaseRelease()
{
properties.setDatabaseEnabled(true);
PromptVersion version = version("2.0.0", PromptVersion.STATUS_PUBLISHED);
when(mapper.selectPublishedByGenerateType("database")).thenReturn(version);
when(mapper.selectExecutableByCodeAndVersion("front.database", "2.0.0"))
@@ -58,7 +44,6 @@ public class DatabasePromptRegistryTest
@Test
public void rejectsStoredContentHashDrift()
{
properties.setDatabaseEnabled(true);
PromptVersion version = version("2.0.0", PromptVersion.STATUS_PUBLISHED);
version.setContentHash("tampered");
when(mapper.selectPublishedByGenerateType("database")).thenReturn(version);
@@ -75,32 +60,22 @@ public class DatabasePromptRegistryTest
}
@Test
public void obsoleteLegacyBuilderReleaseIsRejectedInsteadOfSilentlyFallingBack()
public void acceptsContractOwnedByManagedVersion()
{
properties.setDatabaseEnabled(true);
PromptVersion version = version("1.0.0", PromptVersion.STATUS_PUBLISHED);
version.setUserPromptContract("legacy-builder:database:v1");
PromptVersion version = version("2.0.0", PromptVersion.STATUS_PUBLISHED);
version.setUserPromptContract("context-json:database:v2");
version.setContentHash(fingerprints.fingerprint(version.getGenerateType(),
version.getPromptCode(), version.getVersion(), version.getSystemPrompt(),
version.getUserPromptContract(), version.getUserPromptTemplate()));
when(mapper.selectPublishedByGenerateType("database")).thenReturn(version);
try
{
registry.resolve("database");
fail("Expected incompatible stored contract");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("not implemented"));
}
assertEquals("context-json:database:v2",
registry.resolve("database").getUserPromptContract());
}
@Test
public void missingDatabaseReleaseFailsClosedByDefault()
{
properties.setDatabaseEnabled(true);
try
{
registry.resolve("database");
@@ -112,16 +87,6 @@ public class DatabasePromptRegistryTest
}
}
@Test
public void explicitBootstrapFallbackCanUseBuiltinRelease()
{
properties.setDatabaseEnabled(true);
properties.setBuiltinFallbackEnabled(true);
assertEquals(builtin.resolve("database").getFingerprint(),
registry.resolve("database").getFingerprint());
}
private PromptVersion version(String label, String status)
{
PromptVersion version = new PromptVersion();
@@ -132,7 +97,7 @@ public class DatabasePromptRegistryTest
version.setVersion(label);
version.setSystemPrompt("database system v2");
version.setUserPromptContract("context-json:database:v1");
version.setUserPromptTemplate(BuiltinPromptRegistry.DATABASE_USER_PROMPT_TEMPLATE);
version.setUserPromptTemplate("{{input}}");
version.setProviderCode("deepseek");
version.setModel("deepseek-chat");
version.setStatus(status);

View File

@@ -23,17 +23,15 @@ public class PromptRegistrySchemaScriptTest
}
@Test
public void mapperKeepsVersionContentImmutableOutsideGuardedBootstrapRepair() throws Exception
public void mapperKeepsManagedVersionContentImmutable() throws Exception
{
String mapper = read(repoRoot().resolve(
"ruoyi-generator/src/main/resources/mapper/factory/PromptRegistryMapper.xml"));
assertTrue(mapper.contains("insertPromptVersion"));
assertTrue(mapper.contains("publishPromptVersion"));
assertTrue(mapper.contains("retirePublishedVersions"));
assertTrue(mapper.contains("updateInitializedV1"));
assertTrue(mapper.contains("version_label = 'V1'"));
assertTrue(mapper.contains("user_prompt_contract like 'legacy-builder:%'"));
assertTrue(mapper.contains("remark in ('由当前代码内置 Prompt 初始化', '由当前代码内置业务 Prompt 初始化')"));
assertFalse(mapper.contains("updateInitializedV1"));
assertFalse(mapper.contains("legacy-builder:"));
assertFalse(mapper.contains("updatePromptVersionContent"));
}
@@ -47,7 +45,7 @@ public class PromptRegistrySchemaScriptTest
String completeDatabase = read(root.resolve("sql/db.sql"));
assertTrue(controller.contains("/versions/{promptVersionId}/publish"));
assertTrue(controller.contains("/versions/{promptVersionId}/rollback"));
assertTrue(controller.contains("/initialize-v1"));
assertFalse(controller.contains("/initialize-v1"));
assertTrue(menu.contains("generator:prompt:publish"));
assertTrue(menu.contains("generator:prompt:rollback"));
assertTrue(completeDatabase.contains("'Prompt 中心'"));

View File

@@ -0,0 +1,86 @@
package com.ruoyi.generator.factory.ai.stage.oneclick;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.Test;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.generator.domain.front.AiGenerationTask;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.AppBlueprintDesign;
import com.ruoyi.generator.domain.front.dto.AppPageDesign;
import com.ruoyi.generator.domain.front.dto.GenerateAppBlueprintRequest;
import com.ruoyi.generator.domain.front.dto.GenerateFlowConfigRequest;
import com.ruoyi.generator.domain.front.dto.OneClickProjectGenerationResult;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig;
import com.ruoyi.generator.service.front.FlowConfigPageBindingReconciler;
import com.ruoyi.generator.service.front.IAiGenerateService;
public class DesignStageHandlerTest
{
@Test
public void publishesReconciledBlueprintToFollowingOneClickStages()
{
IAiGenerateService ai = mock(IAiGenerateService.class);
AppBlueprintDesign blueprint = blueprint();
SimpleFlowConfig flow = flow();
when(ai.generateAppBlueprint(eq(7L), eq(11L), any(GenerateAppBlueprintRequest.class)))
.thenReturn(blueprint);
when(ai.generateFlowConfig(eq(7L), eq(11L), any(GenerateFlowConfigRequest.class)))
.thenReturn(flow);
AiGenerationTask task = new AiGenerationTask();
task.setUserId(7L);
task.setProjectId(11L);
OneClickGenerationContext context = new OneClickGenerationContext(task,
new AiGenerationTaskCreateRequest(), new OneClickProjectGenerationResult());
new DesignStageHandler(ai, new FlowConfigPageBindingReconciler()).execute(context);
AppBlueprintDesign reconciled = JSON.parseObject(context.getAppBlueprintJson(), AppBlueprintDesign.class);
assertNotNull(findPage(reconciled, "event_detail_page"));
assertEquals("event_detail_page", context.getFlowConfig().getActions().get(0).getButton().getPageCode());
assertEquals("detail.primaryActions", context.getFlowConfig().getActions().get(0).getButton().getSlot());
}
private AppBlueprintDesign blueprint()
{
AppBlueprintDesign blueprint = new AppBlueprintDesign();
AppPageDesign page = new AppPageDesign();
page.setCode("event_catalog_page");
page.setName("活动列表");
page.setMenuCode("events");
page.setPath("/events");
page.setPageType("list");
page.setTableName("event_info");
blueprint.getFrontendPages().add(page);
return blueprint;
}
private SimpleFlowConfig flow()
{
return JSON.parseObject("{\"version\":\"1.1\",\"code\":\"event_signup\","
+ "\"name\":\"活动报名\",\"mainTable\":\"event_signup\",\"statusField\":\"status\","
+ "\"states\":[{\"code\":\"pending\",\"label\":\"待处理\"},"
+ "{\"code\":\"cancelled\",\"label\":\"已取消\"}],\"actions\":[{"
+ "\"code\":\"signup\",\"name\":\"报名\",\"actor\":\"member\","
+ "\"ownerTable\":\"event_info\",\"effects\":[{\"type\":\"create_record\","
+ "\"table\":\"event_signup\",\"values\":{\"status\":\"pending\"}}],"
+ "\"button\":{\"scope\":\"member\",\"pageCode\":\"event_catalog_page\","
+ "\"tableName\":\"event_info\",\"slot\":\"list.rowActions\"}}]}",
SimpleFlowConfig.class);
}
private AppPageDesign findPage(AppBlueprintDesign blueprint, String code)
{
for (AppPageDesign page : blueprint.getFrontendPages())
{
if (code.equals(page.getCode())) return page;
}
return null;
}
}

View File

@@ -209,6 +209,56 @@ public class FlowConfigCompilerTest
assertTrue(compilation.getLoopPlan().getMetrics().get(0).getFilters().isEmpty());
}
@Test
public void sanitizerDefaultsSelfUpdateRelationFieldToOwnerRequestId()
{
SimpleFlowConfig config = JSON.parseObject("{"
+ "\"version\":\"1.1\",\"code\":\"book_return\",\"name\":\"Book return\","
+ "\"mainTable\":\"borrow_record\",\"statusField\":\"status\","
+ "\"states\":[{\"code\":\"borrowing\",\"label\":\"Borrowing\"},"
+ "{\"code\":\"returned\",\"label\":\"Returned\"}],"
+ "\"actions\":[{\"code\":\"return_book\",\"name\":\"Return\","
+ "\"from\":\"borrowing\",\"to\":\"returned\",\"actor\":\"reader\","
+ "\"ownerTable\":\"borrow_record\",\"requestFields\":[\"id\"],"
+ "\"effects\":[{\"type\":\"update_related\",\"table\":\"borrow_record\","
+ "\"values\":{\"return_date\":\"now\"}}]}],\"metrics\":[]}",
SimpleFlowConfig.class);
new FlowConfigSanitizer().sanitize(config);
assertEquals("id", config.getActions().get(0).getEffects().get(0).getRelationField());
new FlowConfigValidator().validate(config);
}
@Test
public void sanitizerDoesNotGuessCrossTableRelationField()
{
SimpleFlowConfig config = JSON.parseObject("{"
+ "\"version\":\"1.1\",\"code\":\"book_return\",\"name\":\"Book return\","
+ "\"mainTable\":\"borrow_record\",\"statusField\":\"status\","
+ "\"states\":[{\"code\":\"borrowing\",\"label\":\"Borrowing\"},"
+ "{\"code\":\"returned\",\"label\":\"Returned\"}],"
+ "\"actions\":[{\"code\":\"return_book\",\"name\":\"Return\","
+ "\"from\":\"borrowing\",\"to\":\"returned\",\"actor\":\"reader\","
+ "\"ownerTable\":\"borrow_record\",\"requestFields\":[\"id\"],"
+ "\"effects\":[{\"type\":\"update_related\",\"table\":\"fine_record\","
+ "\"values\":{\"paid_at\":\"now\"}}]}],\"metrics\":[]}",
SimpleFlowConfig.class);
new FlowConfigSanitizer().sanitize(config);
assertNull(config.getActions().get(0).getEffects().get(0).getRelationField());
try
{
new FlowConfigValidator().validate(config);
fail("Expected cross-table relationField validation to fail");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("effect.relationField"));
}
}
@Test
public void compilesReservationAndBorrowingAsTwoIndependentFlows()
{

View File

@@ -7,9 +7,7 @@ import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
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 org.mockito.InOrder;
@@ -17,7 +15,6 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.factory.ai.model.ModelGateway;
import com.ruoyi.generator.factory.ai.model.ModelGatewayRouter;
import com.ruoyi.generator.factory.ai.prompt.PromptFingerprintService;
import com.ruoyi.generator.factory.ai.prompt.BuiltinPromptRegistry;
import com.ruoyi.generator.factory.ai.prompt.model.PromptTemplate;
import com.ruoyi.generator.factory.ai.prompt.model.PromptVersion;
import com.ruoyi.generator.factory.dsl.ProjectSpecJsonCodec;
@@ -38,8 +35,7 @@ public class PromptTemplateServiceImplTest
gatewayRouter = mock(ModelGatewayRouter.class);
fingerprints = new PromptFingerprintService(
new CanonicalFingerprintService(new ProjectSpecJsonCodec()));
service = new PromptTemplateServiceImpl(mapper, fingerprints, gatewayRouter,
new BuiltinPromptRegistry(fingerprints));
service = new PromptTemplateServiceImpl(mapper, fingerprints, gatewayRouter);
}
@Test
@@ -126,7 +122,7 @@ public class PromptTemplateServiceImplTest
}
@Test
public void rejectsUnimplementedUserPromptContractBeforePublication()
public void publishesContractDefinedByManagedVersion()
{
PromptVersion draft = version(202L, PromptVersion.STATUS_DRAFT);
draft.setUserPromptContract("context-json:database:v2");
@@ -134,88 +130,11 @@ public class PromptTemplateServiceImplTest
draft.getVersion(), draft.getSystemPrompt(), draft.getUserPromptContract()));
when(mapper.selectPromptTemplateForUpdate(101L)).thenReturn(template());
when(mapper.selectPromptVersionById(202L)).thenReturn(draft);
try
{
service.publishPromptVersion(101L, 202L, "admin");
fail("Expected unsupported builder contract");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("not implemented"));
}
}
@Test
public void initializesCurrentBuiltinPromptAsPublishedV1OnlyForEmptyTemplate()
{
PromptTemplate template = template();
when(mapper.selectPromptTemplateList(any(PromptTemplate.class)))
.thenReturn(Collections.singletonList(template));
when(mapper.selectPromptTemplateForUpdate(101L)).thenReturn(template);
when(mapper.selectMaxVersionNo(101L)).thenReturn(0);
when(gatewayRouter.resolve("deepseek")).thenReturn(mock(ModelGateway.class));
when(mapper.insertPromptVersion(any(PromptVersion.class))).thenAnswer(invocation ->
{
PromptVersion value = invocation.getArgument(0);
value.setPromptVersionId(301L);
return 1;
});
when(mapper.publishPromptVersion(301L, "admin")).thenReturn(1);
when(mapper.updateCurrentVersion(101L, 301L, "admin")).thenReturn(1);
when(mapper.publishPromptVersion(202L, "admin")).thenReturn(1);
when(mapper.updateCurrentVersion(101L, 202L, "admin")).thenReturn(1);
assertEquals(1, service.initializeBuiltinV1("admin"));
InOrder order = inOrder(mapper);
order.verify(mapper).insertPromptVersion(any(PromptVersion.class));
order.verify(mapper).publishPromptVersion(301L, "admin");
order.verify(mapper).updateCurrentVersion(101L, 301L, "admin");
}
@Test
public void repairsOnlyTheOriginalGenericSystemInitializedV1()
{
PromptTemplate template = template();
PromptVersion oldV1 = version(301L, PromptVersion.STATUS_PUBLISHED);
oldV1.setVersionNo(1);
oldV1.setVersion("V1");
oldV1.setUserPromptContract("legacy-builder:database:v1");
oldV1.setRemark("由当前代码内置 Prompt 初始化");
when(mapper.selectPromptTemplateList(any(PromptTemplate.class)))
.thenReturn(Collections.singletonList(template));
when(mapper.selectPromptTemplateForUpdate(101L)).thenReturn(template);
when(mapper.selectMaxVersionNo(101L)).thenReturn(1);
when(mapper.selectPromptVersions(101L)).thenReturn(Collections.singletonList(oldV1));
when(gatewayRouter.resolve("deepseek")).thenReturn(mock(ModelGateway.class));
when(mapper.updateInitializedV1(oldV1)).thenReturn(1);
assertEquals(1, service.initializeBuiltinV1("admin"));
assertTrue(oldV1.getSystemPrompt().contains("MySQL"));
assertEquals("由当前代码内置业务 Prompt 初始化", oldV1.getRemark());
verify(mapper).updateInitializedV1(oldV1);
}
@Test
public void repairsTheOriginalBusinessSystemInitializedLegacyV1()
{
PromptTemplate template = template();
PromptVersion oldV1 = version(301L, PromptVersion.STATUS_PUBLISHED);
oldV1.setVersionNo(1);
oldV1.setVersion("V1");
oldV1.setUserPromptContract("legacy-builder:database:v1");
oldV1.setRemark("由当前代码内置业务 Prompt 初始化");
when(mapper.selectPromptTemplateList(any(PromptTemplate.class)))
.thenReturn(Collections.singletonList(template));
when(mapper.selectPromptTemplateForUpdate(101L)).thenReturn(template);
when(mapper.selectMaxVersionNo(101L)).thenReturn(1);
when(mapper.selectPromptVersions(101L)).thenReturn(Collections.singletonList(oldV1));
when(gatewayRouter.resolve("deepseek")).thenReturn(mock(ModelGateway.class));
when(mapper.updateInitializedV1(oldV1)).thenReturn(1);
assertEquals(1, service.initializeBuiltinV1("admin"));
assertEquals("context-json:database:v1", oldV1.getUserPromptContract());
assertEquals("由当前代码内置业务 Prompt 初始化", oldV1.getRemark());
verify(mapper).updateInitializedV1(oldV1);
assertEquals(1, service.publishPromptVersion(101L, 202L, "admin"));
}
private PromptTemplate template()

View File

@@ -7,11 +7,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Test;
import com.ruoyi.generator.factory.ai.prompt.BuiltinPromptRegistry;
import com.ruoyi.generator.factory.ai.prompt.PromptFingerprintService;
import com.ruoyi.generator.factory.ai.prompt.PromptRelease;
import com.ruoyi.generator.factory.dsl.ProjectSpecJsonCodec;
import com.ruoyi.generator.factory.generation.CanonicalFingerprintService;
public class AiPromptExternalizationTest
{
@@ -30,35 +25,19 @@ public class AiPromptExternalizationTest
}
@Test
public void everyExecutableGenerationTypeOwnsManagedTemplates()
public void runtimeRegistryHasNoBuiltinPromptFallback() throws Exception
{
BuiltinPromptRegistry registry = new BuiltinPromptRegistry(new PromptFingerprintService(
new CanonicalFingerprintService(new ProjectSpecJsonCodec())));
String[] types = new String[] { "requirement", "app_blueprint", "flow_config", "database",
"diagram_spec", "paper_outline", "paper_draft", "code_analysis" };
Path source = resolve(
"ruoyi-generator/src/main/java/com/ruoyi/generator/factory/ai/prompt/DatabasePromptRegistry.java",
"src/main/java/com/ruoyi/generator/factory/ai/prompt/DatabasePromptRegistry.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
for (String type : types)
{
PromptRelease release = registry.resolve(type);
assertTrue(type, release.getSystemPrompt() != null && !release.getSystemPrompt().trim().isEmpty());
assertTrue(type, release.getUserPromptTemplate() != null
&& release.getUserPromptTemplate().contains("{{input}}"));
}
}
@Test
public void flowConfigPromptRequiresExplicitStateTransitions()
{
BuiltinPromptRegistry registry = new BuiltinPromptRegistry(new PromptFingerprintService(
new CanonicalFingerprintService(new ProjectSpecJsonCodec())));
PromptRelease release = registry.resolve("flow_config");
assertTrue(release.getSystemPrompt().contains("每个 action 必须有 from 和 to"));
assertTrue(release.getSystemPrompt().contains("不能作为前端可传字段"));
assertTrue(release.getSystemPrompt().contains("metrics.status 如果填写"));
assertTrue(release.getSystemPrompt().contains("create_time、created_at 等创建时间字段必须写为 now"));
assertTrue(release.getSystemPrompt().contains("\"create_time\":\"now\""));
assertTrue(content.contains("selectPublishedByGenerateType"));
assertFalse(content.contains("BuiltinPromptRegistry"));
assertFalse(content.contains("PromptRegistryProperties"));
Path resources = resolve("ruoyi-generator/src/main/resources/factory-prompts",
"src/main/resources/factory-prompts");
assertFalse(Files.exists(resources.resolve("default/system.txt")));
}
private Path resolve(String repositoryPath, String modulePath)

View File

@@ -0,0 +1,126 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.generator.domain.front.dto.AppBlueprintDesign;
import com.ruoyi.generator.domain.front.dto.AppPageDesign;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig;
import com.ruoyi.generator.factory.flow.FlowConfigCompilation;
public class FlowConfigPageBindingReconcilerTest
{
@Test
public void createsGenericResourceDetailPageAndMovesInitiatingAction()
{
AppBlueprintDesign blueprint = blueprint(false);
SimpleFlowConfig config = flowConfig();
FlowConfigCompilation compilation = new FlowConfigContractService().canonicalize(config, blueprint);
SimpleFlowConfig.Action apply = config.getActions().get(0);
assertEquals("course_detail_page", apply.getButton().getPageCode());
assertEquals("detail.primaryActions", apply.getButton().getSlot());
AppPageDesign detail = page(blueprint, "course_detail_page");
assertNotNull(detail);
assertEquals("detail", detail.getPageType());
assertEquals("course_info", detail.getTableName());
assertEquals("course_catalog_page", detail.getParentPageCode());
assertEquals("/courses/:id", detail.getPath());
SimpleFlowConfig.Action cancel = config.getActions().get(1);
assertEquals("my_registrations_page", cancel.getButton().getPageCode());
assertEquals("list.rowActions", cancel.getButton().getSlot());
BusinessLoopPlan.BusinessLoopPageBinding binding = compilation.getLoopPlan().getPageBindings().get(0);
assertEquals("course_detail_page", binding.getPageCode());
assertEquals("detail.primaryActions", binding.getSlot());
}
@Test
public void reusesExistingDetailPageWithoutCreatingDuplicate()
{
AppBlueprintDesign blueprint = blueprint(true);
SimpleFlowConfig config = flowConfig();
boolean changed = new FlowConfigPageBindingReconciler().reconcile(config, blueprint);
assertTrue(changed);
assertEquals("custom_course_view", config.getActions().get(0).getButton().getPageCode());
assertEquals("detail.primaryActions", config.getActions().get(0).getButton().getSlot());
assertEquals(3, blueprint.getFrontendPages().size());
}
@Test(expected = com.ruoyi.common.exception.ServiceException.class)
public void rejectsSlotThatDoesNotMatchBoundPageType()
{
AppBlueprintDesign blueprint = blueprint(false);
SimpleFlowConfig config = flowConfig();
SimpleFlowConfig.Action cancel = config.getActions().get(1);
cancel.getButton().setSlot("detail.primaryActions");
new FlowConfigContractService().canonicalize(config, blueprint);
}
private AppBlueprintDesign blueprint(boolean includeDetail)
{
AppBlueprintDesign blueprint = new AppBlueprintDesign();
blueprint.getFrontendPages().add(page("course_catalog_page", "课程目录", "/courses", "list",
"course_info", "course_catalog", ""));
blueprint.getFrontendPages().add(page("my_registrations_page", "我的报名", "/my/registrations",
"current_user_list", "registration", "my_registrations", ""));
if (includeDetail)
{
blueprint.getFrontendPages().add(page("custom_course_view", "课程介绍", "/courses/:id",
"detail", "course_info", "course_catalog", "course_catalog_page"));
}
return blueprint;
}
private AppPageDesign page(String code, String name, String path, String type, String table,
String menuCode, String parentPageCode)
{
AppPageDesign page = new AppPageDesign();
page.setCode(code);
page.setName(name);
page.setPath(path);
page.setPageType(type);
page.setTableName(table);
page.setMenuCode(menuCode);
page.setParentPageCode(parentPageCode);
return page;
}
private AppPageDesign page(AppBlueprintDesign blueprint, String code)
{
for (AppPageDesign page : blueprint.getFrontendPages())
{
if (page != null && code.equals(page.getCode())) return page;
}
return null;
}
private SimpleFlowConfig flowConfig()
{
return JSON.parseObject("{"
+ "\"version\":\"1.1\",\"code\":\"course_registration\",\"name\":\"课程报名\","
+ "\"mainTable\":\"registration\",\"statusField\":\"status\","
+ "\"states\":[{\"code\":\"pending\",\"label\":\"待处理\"},"
+ "{\"code\":\"cancelled\",\"label\":\"已取消\"}],"
+ "\"actions\":[{\"code\":\"apply_course\",\"name\":\"报名\",\"actor\":\"student\","
+ "\"ownerTable\":\"course_info\",\"requestFields\":[\"id\"],"
+ "\"effects\":[{\"type\":\"create_record\",\"table\":\"registration\","
+ "\"values\":{\"course_id\":\"record.id\",\"student_id\":\"currentUserId\","
+ "\"status\":\"pending\"}}],\"button\":{\"scope\":\"student\","
+ "\"pageCode\":\"course_catalog_page\",\"tableName\":\"course_info\","
+ "\"slot\":\"list.rowActions\"}},{\"code\":\"cancel_registration\","
+ "\"name\":\"取消报名\",\"actor\":\"student\",\"ownerTable\":\"registration\","
+ "\"requestFields\":[\"id\"],\"from\":\"pending\",\"to\":\"cancelled\","
+ "\"effects\":[],\"button\":{\"scope\":\"student\","
+ "\"pageCode\":\"my_registrations_page\",\"tableName\":\"registration\","
+ "\"slot\":\"list.rowActions\"}}],\"metrics\":[]}", SimpleFlowConfig.class);
}
}

View File

@@ -21,6 +21,7 @@ import org.mockito.ArgumentCaptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.config.DeepSeekProperties;
public class HttpDeepSeekClientTest
@@ -130,6 +131,22 @@ public class HttpDeepSeekClientTest
assertTrue(request.getJSONObject("stream_options").getBooleanValue("include_usage"));
}
@Test
public void rejectsInvocationWithoutManagedSystemPrompt() throws Exception
{
HttpDeepSeekClient client = client(false);
try
{
client.chatModel("deepseek-chat", " ", "prompt");
fail("Expected an explicit managed system Prompt");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("published managed Prompt version"));
}
}
@Test
public void parseContentRecordsUsageFromNonStreamingResponse() throws Exception
{

View File

@@ -34,10 +34,6 @@ export function rollbackPromptVersion(promptTemplateId, promptVersionId) {
})
}
export function initializePromptV1() {
return request({ url: '/generator/prompt/initialize-v1', method: 'post' })
}
export function listPromptModels() {
return request({ url: '/generator/prompt/models', method: 'get' })
}

View File

@@ -23,9 +23,6 @@
<el-col :span="1.5">
<el-button v-hasPermi="['generator:prompt:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增模板</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-hasPermi="['generator:prompt:version']" type="primary" plain icon="el-icon-document-add" size="mini" @click="initializeV1">初始化 V1</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-hasPermi="['generator:prompt:debug']" type="success" plain icon="el-icon-video-play" size="mini" @click="openDebugLab()">在线调试</el-button>
</el-col>
@@ -44,7 +41,7 @@
<el-table-column label="当前发布" min-width="130">
<template slot-scope="scope">
<el-tag v-if="scope.row.currentVersion" size="mini" type="success">{{ scope.row.currentVersion }}</el-tag>
<span v-else class="muted">使用内置后备</span>
<span v-else class="muted">未发布</span>
</template>
</el-table-column>
<el-table-column label="发布指纹" min-width="170">
@@ -281,7 +278,6 @@ import {
createPromptVersion,
publishPromptVersion,
rollbackPromptVersion,
initializePromptV1,
listPromptModels,
debugPrompt,
listPromptDebugRuns,
@@ -318,7 +314,7 @@ export default {
versionDrawerOpen: false,
versionDrawerTitle: 'Prompt 版本',
detailLoading: false,
detail: { template: null, versions: [], builtinRelease: null },
detail: { template: null, versions: [] },
versionDialogOpen: false,
versionDialogTitle: '',
versionReadOnly: false,
@@ -428,20 +424,21 @@ export default {
})
},
clearDetail() {
this.detail = { template: null, versions: [], builtinRelease: null }
this.detail = { template: null, versions: [] }
},
openVersionDialog() {
const template = this.detail.template
const builtin = this.detail.builtinRelease || {}
const versions = this.detail.versions || []
const managed = versions.find(item => item.promptVersionId === template.currentVersionId) || versions[0] || {}
this.versionReadOnly = false
this.versionDialogTitle = '新建 Prompt 版本'
this.versionForm = {
version: '',
providerCode: 'deepseek',
model: '',
userPromptContract: builtin.userPromptContract || 'legacy-builder:' + template.generateType + ':v1',
userPromptTemplate: '{{input}}',
systemPrompt: builtin.systemPrompt || '',
providerCode: managed.providerCode || 'deepseek',
model: managed.model || '',
userPromptContract: managed.userPromptContract || '',
userPromptTemplate: managed.userPromptTemplate || '{{input}}',
systemPrompt: managed.systemPrompt || '',
remark: ''
}
this.versionDialogOpen = true
@@ -485,17 +482,6 @@ export default {
this.getList()
}).catch(() => {})
},
initializeV1() {
this.$modal.confirm('将当前业务 Prompt 初始化为 V1 并立即发布;同时修复此前系统生成的同质化 V1不覆盖人工版本。确认继续').then(() => {
return initializePromptV1()
}).then(response => {
this.$modal.msgSuccess(response.msg || 'V1 初始化完成')
this.getList()
if (this.versionDrawerOpen && this.detail.template) {
this.loadDetail(this.detail.template.promptTemplateId)
}
}).catch(() => {})
},
loadModelGateways() {
listPromptModels().then(response => {
this.modelGateways = response.data || []

View File

@@ -0,0 +1,105 @@
-- Clarify the managed flow_config effect contract used by AI generation.
-- Idempotent: publish a new version only when the current prompt does not
-- already explain update_related.relationField.
DELIMITER $$
DROP PROCEDURE IF EXISTS upgrade_flow_relation_field_prompt_20260715$$
CREATE PROCEDURE upgrade_flow_relation_field_prompt_20260715()
BEGIN
DECLARE v_template_id BIGINT;
DECLARE v_current_version_id BIGINT;
DECLARE v_new_version_id BIGINT;
DECLARE v_version_no INT;
DECLARE v_version_label VARCHAR(50);
DECLARE v_generate_type VARCHAR(50);
DECLARE v_prompt_code VARCHAR(100);
DECLARE v_system_prompt LONGTEXT;
DECLARE v_user_prompt_contract VARCHAR(255);
DECLARE v_user_prompt_template LONGTEXT;
DECLARE v_target_prompt LONGTEXT;
DECLARE v_provider_code VARCHAR(50);
DECLARE v_model VARCHAR(100);
DECLARE v_pipeline_release_code VARCHAR(100);
DECLARE v_content_hash CHAR(64);
DECLARE v_old_rule TEXT;
DECLARE v_new_rule TEXT;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
SELECT t.prompt_template_id, t.current_version_id, t.generate_type, t.prompt_code,
v.system_prompt, v.user_prompt_contract, v.user_prompt_template,
v.provider_code, v.model, v.pipeline_release_code
INTO v_template_id, v_current_version_id, v_generate_type, v_prompt_code,
v_system_prompt, v_user_prompt_contract, v_user_prompt_template,
v_provider_code, v_model, v_pipeline_release_code
FROM factory_prompt_template t
JOIN factory_prompt_version v ON v.prompt_version_id = t.current_version_id
WHERE t.prompt_code = 'front.flow_config'
FOR UPDATE;
SET v_old_rule = '6. decrease/increase 必须给 table、relationField、field、amountdecrease 还要给 min。';
SET v_new_rule = '6. decrease、increase、update_related 必须给 table 和 relationFieldrelationField 是 ownerTable 上保存目标表主键的字段。update_related 更新当前记录自身effect.table 等于 ownerTablerelationField 必须填写 ownerTable 主键请求字段,通常为 id。decrease/increase 还必须给 field、amountdecrease 还要给 min只有 create_record 可以省略 relationField。';
IF LOCATE('只有 create_record 可以省略 relationField', v_system_prompt) = 0 THEN
IF LOCATE(v_old_rule, v_system_prompt) > 0 THEN
SET v_target_prompt = REPLACE(v_system_prompt, v_old_rule, v_new_rule);
ELSE
SET v_target_prompt = CONCAT(v_system_prompt, CHAR(10), v_new_rule);
END IF;
SELECT COALESCE(MAX(version_no), 0) + 1
INTO v_version_no
FROM factory_prompt_version
WHERE prompt_template_id = v_template_id
FOR UPDATE;
SET v_version_label = CONCAT('V', v_version_no);
SET v_content_hash = SHA2(CONCAT(
'{"generateType":', JSON_QUOTE(v_generate_type),
',"promptCode":', JSON_QUOTE(v_prompt_code),
',"systemPrompt":', JSON_QUOTE(v_target_prompt),
',"userPromptContract":', JSON_QUOTE(v_user_prompt_contract),
',"userPromptTemplate":', JSON_QUOTE(v_user_prompt_template),
',"version":', JSON_QUOTE(v_version_label), '}'), 256);
INSERT INTO factory_prompt_version
(prompt_template_id, version_no, version_label, system_prompt,
user_prompt_contract, user_prompt_template, provider_code, model,
content_hash, status, parent_version_id, pipeline_release_code,
published_by, published_at, create_by, create_time, remark)
VALUES
(v_template_id, v_version_no, v_version_label, v_target_prompt,
v_user_prompt_contract, v_user_prompt_template, v_provider_code, v_model,
v_content_hash, 'DRAFT', v_current_version_id, v_pipeline_release_code,
'', NULL, 'system-upgrade', NOW(),
'Require relationField for update_related effects');
SET v_new_version_id = LAST_INSERT_ID();
UPDATE factory_prompt_version
SET status = 'RETIRED'
WHERE prompt_template_id = v_template_id
AND status = 'PUBLISHED'
AND prompt_version_id <> v_new_version_id;
UPDATE factory_prompt_version
SET status = 'PUBLISHED', published_by = 'system-upgrade', published_at = NOW()
WHERE prompt_version_id = v_new_version_id AND status = 'DRAFT';
UPDATE factory_prompt_template
SET current_version_id = v_new_version_id,
update_by = 'system-upgrade', update_time = NOW()
WHERE prompt_template_id = v_template_id;
END IF;
COMMIT;
END$$
CALL upgrade_flow_relation_field_prompt_20260715()$$
DROP PROCEDURE upgrade_flow_relation_field_prompt_20260715$$
DELIMITER ;

View File

@@ -0,0 +1,161 @@
-- Upgrade the managed Prompt releases used by app blueprint and flow generation.
-- Idempotent: a template is only versioned when its current published content
-- does not already contain the resource-detail binding rule.
DELIMITER $$
DROP PROCEDURE IF EXISTS upgrade_resource_detail_binding_prompts_20260715$$
CREATE PROCEDURE upgrade_resource_detail_binding_prompts_20260715()
BEGIN
DECLARE v_template_id BIGINT;
DECLARE v_current_version_id BIGINT;
DECLARE v_new_version_id BIGINT;
DECLARE v_version_no INT;
DECLARE v_version_label VARCHAR(50);
DECLARE v_generate_type VARCHAR(50);
DECLARE v_prompt_code VARCHAR(100);
DECLARE v_system_prompt LONGTEXT;
DECLARE v_user_prompt_contract VARCHAR(255);
DECLARE v_user_prompt_template LONGTEXT;
DECLARE v_target_prompt LONGTEXT;
DECLARE v_provider_code VARCHAR(50);
DECLARE v_model VARCHAR(100);
DECLARE v_pipeline_release_code VARCHAR(100);
DECLARE v_content_hash CHAR(64);
DECLARE v_anchor TEXT;
DECLARE v_rule TEXT;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
SELECT t.prompt_template_id, t.current_version_id, t.generate_type, t.prompt_code,
v.system_prompt, v.user_prompt_contract, v.user_prompt_template,
v.provider_code, v.model, v.pipeline_release_code
INTO v_template_id, v_current_version_id, v_generate_type, v_prompt_code,
v_system_prompt, v_user_prompt_contract, v_user_prompt_template,
v_provider_code, v_model, v_pipeline_release_code
FROM factory_prompt_template t
JOIN factory_prompt_version v ON v.prompt_version_id = t.current_version_id
WHERE t.prompt_code = 'front.app_blueprint'
FOR UPDATE;
SET v_rule = '需求中只要存在用户针对单条资源记录发起并创建业务记录的动作(例如预约、申请、借用、报名、下单),就必须为资源列表创建同 tableName 的 detail 子页面parentPageCode 指向资源列表页path 使用列表路径加 /:id。此类动作后续必须落在详情页主操作区不能依赖列表行操作。';
IF LOCATE('同 tableName 的 detail 子页面', v_user_prompt_template) = 0 THEN
SET v_anchor = 'frontendMenus 顶层菜单应指向 pageCode详情页作为子页面不创建顶层详情菜单。';
IF LOCATE(v_anchor, v_user_prompt_template) > 0 THEN
SET v_target_prompt = REPLACE(v_user_prompt_template, v_anchor,
CONCAT(v_anchor, CHAR(10), v_rule));
ELSE
SET v_target_prompt = CONCAT(v_user_prompt_template, CHAR(10), v_rule);
END IF;
SELECT COALESCE(MAX(version_no), 0) + 1
INTO v_version_no
FROM factory_prompt_version
WHERE prompt_template_id = v_template_id
FOR UPDATE;
SET v_version_label = CONCAT('V', v_version_no);
SET v_content_hash = SHA2(CONCAT(
'{"generateType":', JSON_QUOTE(v_generate_type),
',"promptCode":', JSON_QUOTE(v_prompt_code),
',"systemPrompt":', JSON_QUOTE(v_system_prompt),
',"userPromptContract":', JSON_QUOTE(v_user_prompt_contract),
',"userPromptTemplate":', JSON_QUOTE(v_target_prompt),
',"version":', JSON_QUOTE(v_version_label), '}'), 256);
INSERT INTO factory_prompt_version
(prompt_template_id, version_no, version_label, system_prompt,
user_prompt_contract, user_prompt_template, provider_code, model,
content_hash, status, parent_version_id, pipeline_release_code,
published_by, published_at, create_by, create_time, remark)
VALUES
(v_template_id, v_version_no, v_version_label, v_system_prompt,
v_user_prompt_contract, v_target_prompt, v_provider_code, v_model,
v_content_hash, 'DRAFT', v_current_version_id, v_pipeline_release_code,
'', NULL, 'system-upgrade', NOW(),
'Resource initiating actions require a same-table detail page');
SET v_new_version_id = LAST_INSERT_ID();
UPDATE factory_prompt_version
SET status = 'RETIRED'
WHERE prompt_template_id = v_template_id
AND status = 'PUBLISHED'
AND prompt_version_id <> v_new_version_id;
UPDATE factory_prompt_version
SET status = 'PUBLISHED', published_by = 'system-upgrade', published_at = NOW()
WHERE prompt_version_id = v_new_version_id AND status = 'DRAFT';
UPDATE factory_prompt_template
SET current_version_id = v_new_version_id,
update_by = 'system-upgrade', update_time = NOW()
WHERE prompt_template_id = v_template_id;
END IF;
SELECT t.prompt_template_id, t.current_version_id, t.generate_type, t.prompt_code,
v.system_prompt, v.user_prompt_contract, v.user_prompt_template,
v.provider_code, v.model, v.pipeline_release_code
INTO v_template_id, v_current_version_id, v_generate_type, v_prompt_code,
v_system_prompt, v_user_prompt_contract, v_user_prompt_template,
v_provider_code, v_model, v_pipeline_release_code
FROM factory_prompt_template t
JOIN factory_prompt_version v ON v.prompt_version_id = t.current_version_id
WHERE t.prompt_code = 'front.flow_config'
FOR UPDATE;
SET v_rule = '12. 用户针对单条资源记录发起并创建主业务记录的动作ownerTable 不等于 mainTable、无 from/to、create_record 到 mainTable必须绑定同 ownerTable 的 detail 页面和 detail.primaryActions例如预约、申请、借用、报名、下单。还书、取消、审核等已有业务记录上的状态流转动作仍可绑定业务记录列表的 list.rowActions。禁止把资源发起动作放在资源列表的 list.rowActions。';
IF LOCATE('禁止把资源发起动作放在资源列表的 list.rowActions', v_system_prompt) = 0 THEN
SET v_target_prompt = CONCAT(v_system_prompt, CHAR(10), v_rule);
SELECT COALESCE(MAX(version_no), 0) + 1
INTO v_version_no
FROM factory_prompt_version
WHERE prompt_template_id = v_template_id
FOR UPDATE;
SET v_version_label = CONCAT('V', v_version_no);
SET v_content_hash = SHA2(CONCAT(
'{"generateType":', JSON_QUOTE(v_generate_type),
',"promptCode":', JSON_QUOTE(v_prompt_code),
',"systemPrompt":', JSON_QUOTE(v_target_prompt),
',"userPromptContract":', JSON_QUOTE(v_user_prompt_contract),
',"userPromptTemplate":', JSON_QUOTE(v_user_prompt_template),
',"version":', JSON_QUOTE(v_version_label), '}'), 256);
INSERT INTO factory_prompt_version
(prompt_template_id, version_no, version_label, system_prompt,
user_prompt_contract, user_prompt_template, provider_code, model,
content_hash, status, parent_version_id, pipeline_release_code,
published_by, published_at, create_by, create_time, remark)
VALUES
(v_template_id, v_version_no, v_version_label, v_target_prompt,
v_user_prompt_contract, v_user_prompt_template, v_provider_code, v_model,
v_content_hash, 'DRAFT', v_current_version_id, v_pipeline_release_code,
'', NULL, 'system-upgrade', NOW(),
'Resource initiating actions bind to detail primary actions');
SET v_new_version_id = LAST_INSERT_ID();
UPDATE factory_prompt_version
SET status = 'RETIRED'
WHERE prompt_template_id = v_template_id
AND status = 'PUBLISHED'
AND prompt_version_id <> v_new_version_id;
UPDATE factory_prompt_version
SET status = 'PUBLISHED', published_by = 'system-upgrade', published_at = NOW()
WHERE prompt_version_id = v_new_version_id AND status = 'DRAFT';
UPDATE factory_prompt_template
SET current_version_id = v_new_version_id,
update_by = 'system-upgrade', update_time = NOW()
WHERE prompt_template_id = v_template_id;
END IF;
COMMIT;
END$$
CALL upgrade_resource_detail_binding_prompts_20260715()$$
DROP PROCEDURE upgrade_resource_detail_binding_prompts_20260715$$
DELIMITER ;

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="info">
<appender-ref ref="console" />
</root>
</configuration>

View File

@@ -0,0 +1 @@
20464

View File

@@ -0,0 +1 @@
33372

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
"@vue/cli-plugin-babel/preset"
]
}

View File

@@ -0,0 +1,24 @@
{
"name": "product-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vue-cli-service serve --host 0.0.0.0",
"serve": "vue-cli-service serve --host 0.0.0.0",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^0.27.2",
"core-js": "^3.8.3",
"echarts": "5.4.0",
"element-ui": "^2.15.14",
"quill": "^1.3.7",
"vue": "^2.6.14",
"vue-quill-editor": "^3.0.6",
"vue-router": "^3.6.5"
},
"devDependencies": {
"@vue/cli-service": "^4.5.19",
"vue-template-compiler": "^2.6.14"
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>演示商品</title>
</head>
<body>
<noscript>请启用 JavaScript 后访问。</noscript>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,543 @@
<template>
<div class="portal-shell">
<template v-if="!isLoginRoute">
<header class="top-nav">
<router-link class="brand" to="/demo/product">
<span class="brand-logo" aria-hidden="true"><i></i></span>
<span>演示商品</span>
</router-link>
<nav class="portal-nav" aria-label="主导航">
<div class="portal-nav-group">
<router-link class="portal-nav-parent" to="/demo/product" exact>首页</router-link>
</div>
</nav>
<div class="user-area">
<template v-if="currentUser">
<button class="user-chip" type="button">
<span class="avatar">{{ userInitial }}</span>
<span>{{ currentUserName }}</span>
</button>
<button class="logout-link" type="button" @click="logout">退出</button>
</template>
<button v-else class="login-link" type="button" @click="goLogin">登录</button>
</div>
</header>
<section v-if="isHomeRoute" class="hero-banner" aria-label="项目横幅">
<div class="hero-pattern" aria-hidden="true"></div>
<div class="hero-content">
<span class="hero-eyebrow">WELCOME</span>
<h1>演示商品</h1>
<p>发现内容连接服务轻松完成每一次访问</p>
</div>
</section>
</template>
<main :class="['portal-main', { 'portal-main-login': isLoginRoute }]">
<router-view />
</main>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
currentUser: null
}
},
computed: {
isLoginRoute() {
return this.$route.path === "/login"
},
isHomeRoute() {
return this.$route.path === "/demo/product"
},
currentUserName() {
return this.currentUser && (this.currentUser.displayName || this.currentUser.account || this.currentUser.nickname || this.currentUser.username || "User")
},
userInitial() {
const name = this.currentUserName || "U"
return name.slice(0, 1).toUpperCase()
}
},
mounted() {
this.loadUser()
window.addEventListener("portal-user-change", this.loadUser)
},
beforeDestroy() {
window.removeEventListener("portal-user-change", this.loadUser)
},
methods: {
canShowMenu(visibleRoles) {
if (!Array.isArray(visibleRoles) || visibleRoles.length === 0) {
return true
}
const roleCode = this.currentUser && this.currentUser.roleCode
return Boolean(roleCode) && visibleRoles.indexOf(roleCode) !== -1
},
isNavGroupActive(paths) {
return Array.isArray(paths) && paths.indexOf(this.$route.path) !== -1
},
loadUser() {
const raw = localStorage.getItem("portal-user")
if (!raw) {
this.currentUser = null
return
}
try {
this.currentUser = JSON.parse(raw)
} catch (error) {
localStorage.removeItem("portal-user")
this.currentUser = null
}
},
goLogin() {
this.$router.push({
path: "/login",
query: { redirect: this.$route.fullPath }
})
},
logout() {
this.request.post("/auth/logout").catch(() => {})
localStorage.removeItem("portal-token")
localStorage.removeItem("portal-user")
window.dispatchEvent(new Event("portal-user-change"))
this.$router.push("/login")
}
}
}
</script>
<style>
:root {
--portal-primary: #167d83;
--portal-primary-strong: #11656a;
--portal-primary-soft: #eaf7f7;
--portal-accent: #2f72d6;
--portal-text: #172033;
--portal-text-secondary: #5f6b7a;
--portal-border: #e3e9f1;
--portal-surface: #ffffff;
--portal-background: #f5f7fb;
--portal-radius-sm: 8px;
--portal-radius-md: 12px;
--portal-radius-lg: 18px;
--portal-shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.06);
--portal-shadow-md: 0 18px 44px rgba(15, 23, 42, 0.12);
--portal-content-width: 1200px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--portal-background);
color: var(--portal-text);
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
button:focus-visible,
a:focus-visible {
outline: 3px solid rgba(47, 114, 214, 0.28);
outline-offset: 2px;
}
.portal-shell {
min-height: 100vh;
background: var(--portal-background);
}
.top-nav {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: minmax(180px, auto) minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
min-height: 72px;
padding: 0 32px;
color: var(--portal-text);
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid var(--portal-border);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
backdrop-filter: blur(14px);
}
.brand,
.portal-nav,
.user-area,
.user-chip {
display: inline-flex;
align-items: center;
}
.brand {
gap: 10px;
min-width: 0;
font-size: 18px;
font-weight: 700;
}
.brand span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-logo {
position: relative;
display: inline-block;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
overflow: hidden;
border-radius: 11px;
color: #ffffff;
background: linear-gradient(135deg, var(--portal-primary), var(--portal-accent));
box-shadow: 0 7px 18px rgba(22, 125, 131, 0.24);
}
.brand-logo::before,
.brand-logo::after,
.brand-logo i {
position: absolute;
display: block;
content: "";
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
transform: rotate(-35deg);
}
.brand-logo::before {
width: 20px;
height: 7px;
top: 9px;
left: 7px;
}
.brand-logo i {
width: 15px;
height: 6px;
top: 17px;
left: 11px;
}
.brand-logo::after {
width: 10px;
height: 5px;
top: 25px;
left: 15px;
}
.portal-nav {
justify-content: center;
gap: 8px;
min-width: 0;
height: 100%;
overflow: visible;
}
.portal-nav-group {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
min-height: 72px;
}
.portal-nav-parent,
.portal-nav-toggle,
.portal-nav-child {
display: inline-flex;
align-items: center;
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.portal-nav-parent {
min-height: 36px;
padding: 0 14px;
border-radius: var(--portal-radius-sm);
color: var(--portal-text);
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.portal-nav-parent:hover,
.portal-nav-parent.router-link-active,
.portal-nav-group.active .portal-nav-parent {
color: var(--portal-primary-strong);
background: var(--portal-primary-soft);
}
.portal-nav-directory {
cursor: default;
}
.portal-nav-toggle {
justify-content: center;
width: 28px;
height: 28px;
margin-left: -8px;
border-radius: 6px;
color: #64748b;
}
.portal-nav-group:hover .portal-nav-toggle,
.portal-nav-group:focus-within .portal-nav-toggle,
.portal-nav-group.active .portal-nav-toggle {
color: var(--portal-primary-strong);
background: var(--portal-primary-soft);
}
.portal-nav-dropdown {
position: absolute;
top: calc(100% - 8px);
left: 0;
z-index: 30;
display: none;
min-width: 168px;
padding: 8px;
border: 1px solid var(--portal-border);
border-radius: var(--portal-radius-md);
background: var(--portal-surface);
box-shadow: var(--portal-shadow-md);
}
.portal-nav-group:hover .portal-nav-dropdown,
.portal-nav-group:focus-within .portal-nav-dropdown {
display: grid;
gap: 4px;
}
.portal-nav-child {
min-height: 34px;
padding: 0 10px;
border-radius: 6px;
color: #334155;
font-size: 14px;
white-space: nowrap;
}
.portal-nav-child:hover,
.portal-nav-child.router-link-active {
color: var(--portal-primary-strong);
background: var(--portal-primary-soft);
}
.user-area {
justify-content: flex-end;
gap: 10px;
min-width: 130px;
}
.user-chip,
.login-link,
.logout-link {
border: 1px solid var(--portal-border);
color: var(--portal-text-secondary);
background: var(--portal-surface);
cursor: pointer;
}
.user-chip {
gap: 8px;
min-height: 38px;
padding: 0 10px 0 0;
border-radius: 999px;
}
.avatar {
display: inline-grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #bfe4e5;
border-radius: 50%;
color: var(--portal-primary-strong);
background: var(--portal-primary-soft);
font-weight: 700;
}
.logout-link,
.login-link {
min-height: 32px;
padding: 0 10px;
border-radius: var(--portal-radius-sm);
}
.logout-link:hover,
.login-link:hover {
color: var(--portal-primary-strong);
background: var(--portal-primary-soft);
}
.hero-banner {
position: relative;
display: grid;
min-height: 228px;
place-items: center;
overflow: hidden;
background:
linear-gradient(132deg, rgba(20, 124, 131, 0.97), rgba(47, 114, 214, 0.91)),
var(--portal-primary);
}
.hero-pattern {
position: absolute;
inset: 0;
opacity: 0.58;
background:
linear-gradient(42deg, transparent 0 18%, rgba(255, 255, 255, 0.18) 18% 27%, transparent 27% 100%),
linear-gradient(132deg, transparent 0 48%, rgba(246, 186, 51, 0.32) 48% 55%, transparent 55% 100%);
}
.hero-content {
position: relative;
z-index: 1;
width: min(var(--portal-content-width), calc(100% - 48px));
margin: 0 auto;
text-align: left;
}
.hero-eyebrow {
display: inline-block;
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.18em;
}
.hero-banner h1 {
max-width: 780px;
margin: 0;
color: #ffffff;
font-size: clamp(34px, 4vw, 48px);
font-weight: 700;
letter-spacing: 0;
line-height: 1.2;
text-shadow: 0 12px 28px rgba(15, 23, 42, 0.22);
}
.hero-banner p {
max-width: 620px;
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.86);
font-size: 16px;
}
.portal-main {
width: min(var(--portal-content-width), calc(100% - 48px));
margin: 0 auto;
padding: 32px 0 52px;
}
.portal-main-login {
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
}
@media (max-width: 900px) {
.top-nav {
grid-template-columns: 1fr auto;
gap: 10px;
height: auto;
min-height: 56px;
padding: 10px 16px;
}
.portal-nav {
grid-column: 1 / -1;
justify-content: flex-start;
order: 3;
gap: 6px;
overflow-x: auto;
overflow-y: visible;
}
.portal-nav-group {
min-height: 38px;
}
.portal-nav-parent {
min-height: 34px;
padding: 0 12px;
}
.portal-nav-dropdown {
position: fixed;
top: 104px;
left: 16px;
right: 16px;
}
.hero-banner {
min-height: 176px;
}
.hero-content {
width: min(100% - 32px, var(--portal-content-width));
}
.hero-banner p {
margin-top: 10px;
font-size: 14px;
}
.portal-main {
width: min(calc(100% - 28px), var(--portal-content-width));
padding: 22px 0 36px;
}
}
@media (max-width: 560px) {
.brand {
max-width: calc(100vw - 150px);
}
.brand span:last-child {
font-size: 16px;
}
.user-area {
min-width: 0;
}
.user-chip > span:last-child {
display: none;
}
.hero-banner {
min-height: 160px;
}
.hero-eyebrow {
margin-bottom: 8px;
}
}
</style>

View File

@@ -0,0 +1,21 @@
import Vue from "vue"
import ElementUI from "element-ui"
import VueQuillEditor from "vue-quill-editor"
import "element-ui/lib/theme-chalk/index.css"
import "quill/dist/quill.core.css"
import "quill/dist/quill.snow.css"
import App from "./App.vue"
import router from "./router"
import request from "./utils/request"
import dict from "./utils/dict"
Vue.config.productionTip = false
Vue.use(ElementUI)
Vue.use(VueQuillEditor)
Vue.prototype.request = request
Vue.prototype._dict = dict
new Vue({
router,
render: h => h(App)
}).$mount("#app")

View File

@@ -0,0 +1,50 @@
import Vue from "vue"
import Router from "vue-router"
import Login from "@/views/login/index.vue"
import Product from "@/views/demo/product/index.vue"
Vue.use(Router)
const router = new Router({
mode: "hash",
routes: [
{
path: "/",
redirect: "/demo/product"
},
{
path: "/login",
name: "Login",
component: Login
},
{
path: "/demo/product",
name: "Product",
component: Product,
meta: { requiresAuth: false }
},
{
path: "/secondary",
name: "Secondary",
component: Product,
meta: { requiresAuth: false }
}
]
})
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some(record => record.meta && record.meta.requiresAuth)
const hasToken = Boolean(localStorage.getItem("portal-token"))
if (requiresAuth && !hasToken) {
next({
path: "/login",
query: { redirect: to.fullPath }
})
return
}
next()
})
export default router

View File

@@ -0,0 +1 @@
body { margin: 0; }

View File

@@ -0,0 +1,32 @@
const dictData = {}
const emptyDict = []
function normalizeOptions(options) {
if (!Array.isArray(options)) {
return emptyDict
}
return options.map((option) => {
const label = option.label || option.name || option.value
const value = option.value || label
return {
...option,
label,
name: option.name || label,
value
}
})
}
export default {
getDictDataByType(type) {
return normalizeOptions(dictData[type])
},
getDictLabel(type, value) {
if (value === undefined || value === null || value === "") {
return "-"
}
const matched = normalizeOptions(dictData[type]).find((option) => String(option.value) === String(value))
return matched ? matched.label : value
}
}

View File

@@ -0,0 +1,6 @@
const page = { data: { records: [], total: 0 } }
export default {
get() { return Promise.resolve(page) },
post(path) { return Promise.resolve(path === '/auth/login' ? { code: '200', data: { token: 'preview-token', user: { displayName: 'Preview User' } } } : { code: '200' }) },
delete() { return Promise.resolve({ code: '200' }) }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
<template>
<section class="portal-login">
<div class="login-visual" aria-hidden="true">
<div class="login-brand">
<span class="login-logo"></span>
<strong>演示商品</strong>
</div>
<h1>欢迎使用</h1>
<p>登录后继续访问项目功能与个人服务</p>
</div>
<div class="login-panel">
<div class="login-heading">
<span>欢迎回来</span>
<h2>账号登录</h2>
</div>
<el-alert
v-if="roleOptions.length === 0"
title="当前没有可登录的前台角色,请联系管理员检查系统角色配置。"
type="warning"
:closable="false"
class="login-alert"
/>
<el-form :model="form" label-position="top" class="login-form" @submit.native.prevent>
<el-form-item label="账号">
<el-input v-model="form.account" placeholder="请输入账号" prefix-icon="el-icon-user" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" prefix-icon="el-icon-lock" show-password />
</el-form-item>
<el-button type="primary" class="login-button" :loading="loading" :disabled="roleOptions.length === 0" @click="handleLogin">登录</el-button>
</el-form>
</div>
</section>
</template>
<script>
const roleOptions = [
]
const defaultRole = roleOptions.length > 0 ? roleOptions[0] : null
export default {
name: "PortalLogin",
data() {
return {
loading: false,
roleOptions,
form: {
roleCode: defaultRole ? defaultRole.value : "",
account: defaultRole ? defaultRole.sampleAccount : "",
password: "123456"
}
}
},
watch: {
"form.roleCode"(roleCode) {
const role = this.roleOptions.find(item => item.value === roleCode)
if (role && (!this.form.account || this.roleOptions.some(item => item.sampleAccount === this.form.account))) {
this.form.account = role.sampleAccount
}
}
},
methods: {
handleLogin() {
if (!this.form.roleCode) {
this.$message.warning("请选择登录角色")
return
}
if (!this.form.account || !this.form.password) {
this.$message.warning("请输入账号和密码")
return
}
this.loading = true
this.request.post("/auth/login", this.form)
.then(res => {
if (!res || res.code !== "200" || !res.data || !res.data.token) {
throw new Error((res && res.msg) || "登录失败")
}
localStorage.setItem("portal-token", res.data.token)
localStorage.setItem("portal-user", JSON.stringify(res.data.user || {
account: this.form.account,
displayName: this.form.account,
roleCode: this.form.roleCode
}))
window.dispatchEvent(new Event("portal-user-change"))
this.$message.success("登录成功")
const redirect = this.$route.query.redirect || "/demo/product"
this.$router.push(redirect)
})
.catch(error => {
this.$message.error(error.message || "登录失败")
})
.finally(() => {
this.loading = false
})
}
}
}
</script>
<style scoped>
.portal-login {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(430px, 36vw);
min-height: 100vh;
background:
linear-gradient(132deg, rgba(20, 124, 131, 0.97), rgba(47, 114, 214, 0.91)),
var(--portal-primary, #167d83);
}
.login-visual {
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
overflow: hidden;
padding: 72px clamp(48px, 8vw, 128px);
color: #ffffff;
}
.login-visual::before {
position: absolute;
inset: 0;
display: block;
content: "";
opacity: 0.58;
background:
linear-gradient(42deg, transparent 0 18%, rgba(255, 255, 255, 0.18) 18% 27%, transparent 27% 100%),
linear-gradient(132deg, transparent 0 48%, rgba(246, 186, 51, 0.32) 48% 55%, transparent 55% 100%);
}
.login-brand,
.login-visual h1,
.login-visual p {
position: relative;
z-index: 1;
}
.login-brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 40px;
font-size: 22px;
}
.login-logo {
width: 30px;
height: 30px;
border-radius: 9px 19px 9px 19px;
background: linear-gradient(135deg, #6de5e8 0 38%, #f6c85b 38% 66%, #ffffff 66%);
transform: rotate(45deg);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.18);
}
.login-visual h1,
.login-visual p {
margin: 0;
}
.login-visual h1 {
max-width: 680px;
font-size: clamp(42px, 5vw, 64px);
font-weight: 700;
letter-spacing: 0;
line-height: 1.18;
}
.login-visual p {
max-width: 520px;
margin-top: 20px;
color: rgba(255, 255, 255, 0.88);
font-size: 17px;
line-height: 1.8;
}
.login-panel {
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(40px, 5vw, 64px);
background: #ffffff;
box-shadow: -18px 0 48px rgba(15, 23, 42, 0.08);
}
.login-heading span {
color: var(--portal-primary, #167d83);
font-size: 14px;
font-weight: 700;
}
.login-heading h2 {
margin: 8px 0 28px;
color: #111827;
font-size: 30px;
font-weight: 700;
letter-spacing: 0;
}
.login-alert {
margin-bottom: 18px;
}
.login-form {
display: grid;
gap: 2px;
}
.role-select,
.login-button {
width: 100%;
}
.login-form >>> .el-input__inner,
.login-form >>> .el-select .el-input__inner {
height: 44px;
border-color: #dfe5ec;
border-radius: 8px;
}
.login-form >>> .el-input__inner:focus {
border-color: var(--portal-primary, #167d83);
box-shadow: 0 0 0 3px rgba(22, 125, 131, 0.12);
}
.login-button {
height: 44px;
margin-top: 10px;
border-color: var(--portal-primary, #167d83);
border-radius: 8px;
background: var(--portal-primary, #167d83);
font-weight: 600;
box-shadow: 0 10px 22px rgba(22, 125, 131, 0.18);
}
.login-button:hover,
.login-button:focus {
border-color: var(--portal-primary-strong, #11656a);
background: var(--portal-primary-strong, #11656a);
}
@media (max-width: 880px) {
.portal-login {
grid-template-columns: 1fr;
}
.login-visual {
min-height: 220px;
padding: 34px 26px;
}
.login-visual h1 {
font-size: 34px;
}
.login-brand {
margin-bottom: 28px;
}
.login-panel {
padding: 34px 24px 44px;
box-shadow: none;
}
}
@media (max-width: 520px) {
.login-visual {
min-height: 184px;
padding: 26px 22px;
}
.login-brand {
margin-bottom: 20px;
font-size: 18px;
}
.login-visual h1 {
font-size: 30px;
}
.login-visual p {
margin-top: 10px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,18 @@
const port = process.env.PORT || 8081
const apiBaseUrl = process.env.VUE_APP_API_BASE_URL || "http://localhost:8080"
module.exports = {
transpileDependencies: [],
devServer: {
port,
proxy: {
"/api": {
target: apiBaseUrl,
changeOrigin: true,
pathRewrite: {
"^/api": ""
}
}
}
}
}