Add async AI generation tasks

This commit is contained in:
王鹏
2026-05-24 13:55:14 +08:00
parent 6682a44a43
commit bb7291f7d1
29 changed files with 2304 additions and 30 deletions

View File

@@ -0,0 +1,197 @@
# AI Generation Task Center Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convert long-running DeepSeek generation requests into trackable asynchronous tasks with generation records, basic quota accounting, cost usage, and retry support.
**Architecture:** Keep the existing synchronous `IAiGenerateService` methods as the actual generation engine and add a DB-backed task layer in front of them. A task service creates task rows, reserves quota, and a worker claims pending tasks with a short database lock before executing generation outside the claim transaction. The Vue workbench starts tasks, polls status, and applies the generated result after success.
**Tech Stack:** Spring Boot, MyBatis XML mappers, MySQL tables in `sql/front_workbench.sql` and `sql/db.sql`, existing RuoYi thread pool conventions, Vue 3 + Element Plus.
---
### File Structure
- Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/AiGenerationTask.java`: task queue domain object.
- Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/AiUsageLedger.java`: usage/cost ledger domain object.
- Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/AiQuotaBucket.java`: per-user quota bucket domain object.
- Create DTOs under `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto`: `AiGenerationTaskCreateRequest`, `AiGenerationTaskStatusResponse`, `AiQuotaResponse`.
- Create mapper interfaces and XML files under `ruoyi-generator/src/main/java/com/ruoyi/generator/mapper/front` and `ruoyi-generator/src/main/resources/mapper/front`.
- Create services under `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front`: `IAiGenerationTaskService`, `AiGenerationTaskServiceImpl`, `AiGenerationTaskWorker`, `AiQuotaService`, `AiCostCalculator`, `AiTaskRetryPolicy`.
- Modify `ruoyi-admin/src/main/java/com/ruoyi/web/controller/front/FrontProjectController.java`: add task create/status/retry/cancel/quota/history endpoints.
- Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/AiGenerateServiceImpl.java`: expose execution helpers or keep using the public sync methods from worker.
- Modify `easycode-web/src/api/project.js`: add task APIs.
- Modify `easycode-web/src/views/GenerateView.vue`: replace blocking generation calls with task start + polling.
- Modify SQL scripts: add task, quota, and ledger tables.
- Add focused tests in `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front`.
### Task 1: Task Status and Retry Policy
**Files:**
- Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/AiTaskRetryPolicy.java`
- Test: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/AiTaskRetryPolicyTest.java`
- [ ] **Step 1: Write failing tests**
Test retryable messages: timeout, 429, 5xx, and DeepSeek unavailable retry. Test validation/permission/quota messages do not retry. Test backoff sequence returns 30 seconds, 2 minutes, 5 minutes.
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -pl ruoyi-generator -Dtest=AiTaskRetryPolicyTest test`
Expected: compile failure because `AiTaskRetryPolicy` does not exist.
- [ ] **Step 3: Implement minimal retry policy**
Create constants for max attempts and retry delays. Add `boolean canRetry(String errorMessage, int attempts, int maxAttempts)` and `Date nextRetryTime(int attempts, Date now)`.
- [ ] **Step 4: Run test to verify it passes**
Run: `mvn -pl ruoyi-generator -Dtest=AiTaskRetryPolicyTest test`
Expected: PASS.
### Task 2: Cost and Quota Services
**Files:**
- Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/AiCostCalculator.java`
- Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/AiQuotaService.java`
- Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/AiQuotaBucket.java`
- Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/AiUsageLedger.java`
- Test: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/AiCostCalculatorTest.java`
- Test: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/AiQuotaServiceTest.java`
- [ ] **Step 1: Write failing tests**
Test default quota allows a first task, blocks when daily count reaches 20, blocks when monthly cost reaches 5000 cents, reserves one running task, and releases running count on finish.
- [ ] **Step 2: Run tests to verify they fail**
Run: `mvn -pl ruoyi-generator -Dtest=AiCostCalculatorTest,AiQuotaServiceTest test`
Expected: compile failure for missing services/domain types.
- [ ] **Step 3: Implement minimal services**
Use mapper methods for load/create/update quota rows. Cost calculator returns 0 when tokens are missing and uses configurable defaults for DeepSeek estimates.
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -pl ruoyi-generator -Dtest=AiCostCalculatorTest,AiQuotaServiceTest test`
Expected: PASS.
### Task 3: Task Persistence
**Files:**
- Create: `AiGenerationTask` domain and mapper XML/interface.
- Create: `AiUsageLedgerMapper` and `AiQuotaBucketMapper`.
- Modify: `sql/front_workbench.sql`
- Modify: `sql/db.sql`
- [ ] **Step 1: Write mapper/service tests**
Add tests for creating task rows, selecting task by user/project/task, selecting claimable tasks, updating lock/status, and inserting usage ledger.
- [ ] **Step 2: Run tests to verify they fail**
Run: `mvn -pl ruoyi-generator -Dtest=AiGenerationTaskServiceImplTest test`
Expected: compile failure for missing domain/mapper/service methods.
- [ ] **Step 3: Implement MyBatis domain and XML**
Use status values `QUEUED`, `RUNNING`, `RETRY_WAITING`, `SUCCEEDED`, `FAILED`, `CANCELED`. Add indexes for `(status, next_retry_time)`, `(user_id, project_id)`, and `generation_id`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -pl ruoyi-generator -Dtest=AiGenerationTaskServiceImplTest test`
Expected: PASS.
### Task 4: Task Service and Worker
**Files:**
- Create: `IAiGenerationTaskService`
- Create: `AiGenerationTaskServiceImpl`
- Create: `AiGenerationTaskWorker`
- Modify: `FrontProjectController`
- Test: `AiGenerationTaskServiceImplTest`
- [ ] **Step 1: Write failing service tests**
Test task creation validates project ownership, reserves quota, creates a `front_project_generation` row, creates a queued task, returns status DTO, and deduplicates a still-running same request hash.
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -pl ruoyi-generator -Dtest=AiGenerationTaskServiceImplTest test`
Expected: compile failure or assertion failure before implementation.
- [ ] **Step 3: Implement task creation and status queries**
Generate request hash from `generateType + normalized request JSON`. Store request payload as JSON. Return `taskId`, `status`, `currentStep`, `progress`, `generationId`.
- [ ] **Step 4: Write failing worker tests**
Test worker executes app blueprint, database, and business blueprint tasks by delegating to existing sync service methods, marks success, records elapsed time, inserts ledger, and schedules retry for retryable failures.
- [ ] **Step 5: Implement worker**
Claim tasks using `locked_by` and `locked_until`, run outside the claim transaction, then update status. Keep max attempts at 3.
- [ ] **Step 6: Run tests to verify they pass**
Run: `mvn -pl ruoyi-generator -Dtest=AiGenerationTaskServiceImplTest,AiGenerationTaskWorkerTest test`
Expected: PASS.
### Task 5: REST API
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/front/FrontProjectController.java`
- Test: service tests remain the main safety net.
- [ ] **Step 1: Add controller endpoints**
Add `POST /front/project/{projectId}/ai-tasks`, `GET /front/project/{projectId}/ai-tasks/{taskId}`, `POST /front/project/{projectId}/ai-tasks/{taskId}/retry`, `POST /front/project/{projectId}/ai-tasks/{taskId}/cancel`, `GET /front/project/{projectId}/generations`, and `GET /front/project/ai-quota`.
- [ ] **Step 2: Run compile tests**
Run: `mvn -pl ruoyi-admin -DskipTests compile`
Expected: SUCCESS.
### Task 6: Frontend Polling Experience
**Files:**
- Modify: `easycode-web/src/api/project.js`
- Modify: `easycode-web/src/views/GenerateView.vue`
- [ ] **Step 1: Add task API helpers**
Add `createAiTask`, `getAiTask`, `retryAiTask`, `cancelAiTask`, `getAiQuota`, and `listGenerations`.
- [ ] **Step 2: Update generation handlers**
Replace direct calls to `generateAppBlueprint`, `generateDatabase`, and `generateBusinessBlueprint` with `createAiTask` plus polling. Keep the old direct API helpers for compatibility.
- [ ] **Step 3: Show task state**
Display status, progress, error message, retry button, and quota summary in the existing generate page.
- [ ] **Step 4: Run frontend tests/build**
Run direct `.mjs` tests with Node 20 and run Vite build.
Expected: tests and build pass.
### Task 7: Final Verification
**Files:**
- No new files.
- [ ] **Step 1: Run backend tests**
Run: `mvn test`
Expected: SUCCESS.
- [ ] **Step 2: Run frontend tests/build**
Run easycode-web direct `.mjs` tests and Vite build.
Expected: SUCCESS.
- [ ] **Step 3: Inspect git diff**
Run: `git diff --stat` and `git status --short`.
Expected: only intended AI task center files changed.

View File

@@ -76,6 +76,49 @@ export function generateBusinessBlueprint(projectId, data) {
}).then(unwrap)
}
export function createAiTask(projectId, data) {
return request({
url: `/front/project/${projectId}/ai-tasks`,
method: 'post',
data
}).then(unwrap)
}
export function getAiTask(projectId, taskId) {
return request({
url: `/front/project/${projectId}/ai-tasks/${taskId}`,
method: 'get'
}).then(unwrap)
}
export function retryAiTask(projectId, taskId) {
return request({
url: `/front/project/${projectId}/ai-tasks/${taskId}/retry`,
method: 'post'
}).then(unwrap)
}
export function cancelAiTask(projectId, taskId) {
return request({
url: `/front/project/${projectId}/ai-tasks/${taskId}/cancel`,
method: 'post'
}).then(unwrap)
}
export function getAiQuota() {
return request({
url: '/front/project/ai-quota',
method: 'get'
}).then(unwrap)
}
export function listGenerations(projectId) {
return request({
url: `/front/project/${projectId}/generations`,
method: 'get'
}).then(unwrap)
}
export function getDatabase(projectId) {
return request({
url: `/front/project/${projectId}/database`,

View File

@@ -105,6 +105,31 @@
预览项目
</el-button>
</div>
<div v-if="activeAiTask || aiQuota" class="ai-task-strip">
<div v-if="activeAiTask" class="ai-task-card">
<div class="ai-task-card-header">
<strong>{{ taskTypeLabel(activeAiTask.generateType) }}</strong>
<el-tag size="small" effect="plain">{{ taskStatusLabel(activeAiTask.status) }}</el-tag>
</div>
<el-progress :percentage="Number(activeAiTask.progress || 0)" :stroke-width="8" />
<p class="muted">{{ activeAiTask.currentStep || '-' }}</p>
<p v-if="activeAiTask.errorMessage" class="task-error">{{ activeAiTask.errorMessage }}</p>
<el-button
v-if="activeAiTask.status === 'FAILED' || activeAiTask.status === 'RETRY_WAITING'"
size="small"
type="primary"
@click="handleRetryActiveTask"
>
重试
</el-button>
</div>
<div v-if="aiQuota" class="ai-quota-card">
<span>今日 {{ aiQuota.dailyTaskUsed || 0 }}/{{ aiQuota.dailyTaskLimit || 0 }}</span>
<span>运行中 {{ aiQuota.runningCount || 0 }}/{{ aiQuota.runningLimit || 0 }}</span>
<span>本月成本 {{ aiQuota.monthlyCostUsedCents || 0 }}/{{ aiQuota.monthlyCostLimitCents || 0 }}</span>
</div>
</div>
</div>
</div>
@@ -220,14 +245,14 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DataAnalysis, View } from '@element-plus/icons-vue'
import AppModuleDiagramView from '@/components/AppModuleDiagramView.vue'
import DatabaseDesigner from '@/components/DatabaseDesigner.vue'
import ErDiagramView from '@/components/ErDiagramView.vue'
import { createProject, generateAppBlueprint, generateBusinessBlueprint, generateDatabase, generateProject, getDatabase, getProject, listAvailableModules, saveDatabase, updateProject } from '@/api/project'
import { createAiTask, createProject, generateProject, getAiQuota, getAiTask, getDatabase, getProject, listAvailableModules, retryAiTask, saveDatabase, updateProject } from '@/api/project'
const route = useRoute()
const router = useRouter()
@@ -247,6 +272,9 @@ const appBlueprint = ref(emptyAppBlueprint())
const appBlueprintJson = ref(formatJson(emptyAppBlueprint()))
const database = ref({ tables: [], sql: '', erDiagram: emptyErDiagram(), businessActions: [] })
const availableModules = ref([])
const activeAiTask = ref(null)
const aiQuota = ref(null)
let stopAiPolling = false
const projectForm = reactive({
projectName: '',
keyword: String(route.query.keyword || ''),
@@ -332,6 +360,51 @@ function normalizeBusinessActions(payload) {
return Array.isArray(data) ? data : []
}
function parseTaskResult(task) {
if (!task?.resultPayload) return null
try {
return JSON.parse(task.resultPayload)
} catch (error) {
return task.resultPayload
}
}
function taskTypeLabel(type) {
const labels = {
app_blueprint: '应用蓝图',
database: '数据库',
business_blueprint: '业务蓝图'
}
return labels[type] || type || 'AI 生成'
}
function taskStatusLabel(status) {
const labels = {
QUEUED: '排队中',
RUNNING: '生成中',
RETRY_WAITING: '等待重试',
SUCCEEDED: '已完成',
FAILED: '失败',
CANCELED: '已取消'
}
return labels[status] || status || '-'
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function aiTaskPayload(generateType) {
return {
generateType,
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword,
appBlueprint: appBlueprintJson.value
}
}
function normalizeProjectId(value) {
const raw = Array.isArray(value) ? value[0] : value
const id = Number(raw)
@@ -533,6 +606,57 @@ function validateRequirementInput() {
return true
}
async function refreshAiQuota() {
try {
aiQuota.value = await getAiQuota()
} catch (error) {
aiQuota.value = null
}
}
function applyAiTaskResult(task) {
const payload = parseTaskResult(task)
if (task.generateType === 'app_blueprint') {
appBlueprint.value = normalizeAppBlueprint(payload)
syncAppBlueprintJson()
return
}
if (task.generateType === 'database') {
database.value = normalizeDatabase(payload)
return
}
if (task.generateType === 'business_blueprint') {
database.value = {
...database.value,
businessActions: normalizeBusinessActions(payload)
}
}
}
async function waitForAiTask(id, initialTask) {
let task = initialTask
activeAiTask.value = task
while (!stopAiPolling && task && !['SUCCEEDED', 'FAILED', 'CANCELED'].includes(task.status)) {
await delay(2000)
task = await getAiTask(id, task.taskId)
activeAiTask.value = task
}
if (task?.status === 'SUCCEEDED') {
applyAiTaskResult(task)
await refreshAiQuota()
return task
}
throw new Error(task?.errorMessage || 'AI 生成任务未完成')
}
async function startAiTask(generateType) {
const id = await ensureProject()
const task = await createAiTask(id, aiTaskPayload(generateType))
return waitForAiTask(id, task)
}
async function handleGenerateAppBlueprint() {
if (!validateRequirementInput()) {
return
@@ -541,15 +665,7 @@ async function handleGenerateAppBlueprint() {
generatingAppBlueprint.value = true
errorMessage.value = ''
try {
const id = await ensureProject()
const result = await generateAppBlueprint(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword
})
appBlueprint.value = normalizeAppBlueprint(result)
syncAppBlueprintJson()
await startAiTask('app_blueprint')
ElMessage.success('应用蓝图已生成')
} catch (error) {
errorMessage.value = error.message || '生成应用蓝图失败,请稍后重试。'
@@ -581,15 +697,7 @@ async function handleGenerateDatabase() {
generating.value = true
errorMessage.value = ''
try {
const id = await ensureProject()
const result = await generateDatabase(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword,
appBlueprint: appBlueprintJson.value
})
database.value = normalizeDatabase(result)
await startAiTask('database')
if (!database.value.tables.length) {
errorMessage.value = '后端暂未返回表结构,请稍后重试或手动添加表。'
@@ -618,16 +726,7 @@ async function handleGenerateBusinessBlueprint() {
try {
const id = await ensureProject()
await saveDatabase(id, database.value)
const result = await generateBusinessBlueprint(id, {
projectName: projectForm.projectName,
projectDesc: projectForm.projectDesc,
industryTemplate: projectForm.industryTemplate,
extraRequirements: projectForm.keyword
})
database.value = {
...database.value,
businessActions: normalizeBusinessActions(result)
}
await waitForAiTask(id, await createAiTask(id, aiTaskPayload('business_blueprint')))
ElMessage.success('业务蓝图已生成')
} catch (error) {
errorMessage.value = error.message || '生成业务蓝图失败,请确认表结构已保存后重试。'
@@ -636,6 +735,27 @@ async function handleGenerateBusinessBlueprint() {
}
}
async function handleRetryActiveTask() {
if (!projectId.value || !activeAiTask.value?.taskId) return
errorMessage.value = ''
const type = activeAiTask.value.generateType
if (type === 'app_blueprint') generatingAppBlueprint.value = true
if (type === 'database') generating.value = true
if (type === 'business_blueprint') generatingBusinessBlueprint.value = true
try {
const task = await retryAiTask(projectId.value, activeAiTask.value.taskId)
await waitForAiTask(projectId.value, task)
ElMessage.success(`${taskTypeLabel(type)}已生成`)
} catch (error) {
errorMessage.value = error.message || 'AI 生成任务重试失败'
} finally {
if (type === 'app_blueprint') generatingAppBlueprint.value = false
if (type === 'database') generating.value = false
if (type === 'business_blueprint') generatingBusinessBlueprint.value = false
}
}
async function handleSaveDatabase() {
if (!projectId.value) return
@@ -674,11 +794,16 @@ watch(
)
onMounted(() => {
refreshAiQuota()
loadModuleOptions()
if (projectId.value) {
loadProjectDraft(projectId.value)
}
})
onBeforeUnmount(() => {
stopAiPolling = true
})
</script>
<style scoped lang="scss">
@@ -818,6 +943,34 @@ onMounted(() => {
gap: 18px;
}
.ai-task-strip {
display: grid;
gap: 12px;
max-width: 980px;
}
.ai-task-card {
border: 1px solid var(--border-color);
border-radius: 8px;
display: grid;
gap: 8px;
padding: 12px;
}
.ai-task-card-header,
.ai-quota-card {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.task-error {
color: #c2410c;
font-size: 13px;
margin: 0;
}
@media (max-width: 860px) {
.panel-header {
align-items: stretch;

View File

@@ -19,6 +19,7 @@ import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.front.FrontProject;
import com.ruoyi.generator.domain.front.dto.DatabaseDesignResponse;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.FrontProjectCreateRequest;
import com.ruoyi.generator.domain.front.dto.FrontProjectUpdateRequest;
import com.ruoyi.generator.domain.front.dto.GenerateAppBlueprintRequest;
@@ -26,6 +27,7 @@ import com.ruoyi.generator.domain.front.dto.GenerateBusinessBlueprintRequest;
import com.ruoyi.generator.domain.front.dto.GenerateDatabaseRequest;
import com.ruoyi.generator.domain.front.dto.PreviewFileRequest;
import com.ruoyi.generator.service.front.IAiGenerateService;
import com.ruoyi.generator.service.front.IAiGenerationTaskService;
import com.ruoyi.generator.service.front.IFrontProjectPreviewService;
import com.ruoyi.generator.service.front.IFrontProjectService;
@@ -38,6 +40,8 @@ public class FrontProjectController extends BaseController
@Autowired
private IAiGenerateService aiGenerateService;
@Autowired
private IAiGenerationTaskService aiGenerationTaskService;
@Autowired
private IFrontProjectPreviewService frontProjectPreviewService;
@PostMapping("/create")
@@ -116,6 +120,48 @@ public class FrontProjectController extends BaseController
return success(aiGenerateService.generateBusinessBlueprint(userId, projectId, request));
}
@PostMapping("/{projectId}/ai-tasks")
public AjaxResult createAiTask(@PathVariable Long projectId, @RequestBody AiGenerationTaskCreateRequest request)
{
Long userId = SecurityUtils.getUserId();
return success(aiGenerationTaskService.createTask(userId, projectId, request));
}
@GetMapping("/{projectId}/ai-tasks/{taskId}")
public AjaxResult getAiTask(@PathVariable Long projectId, @PathVariable Long taskId)
{
Long userId = SecurityUtils.getUserId();
return success(aiGenerationTaskService.getTask(userId, projectId, taskId));
}
@PostMapping("/{projectId}/ai-tasks/{taskId}/retry")
public AjaxResult retryAiTask(@PathVariable Long projectId, @PathVariable Long taskId)
{
Long userId = SecurityUtils.getUserId();
return success(aiGenerationTaskService.retryTask(userId, projectId, taskId));
}
@PostMapping("/{projectId}/ai-tasks/{taskId}/cancel")
public AjaxResult cancelAiTask(@PathVariable Long projectId, @PathVariable Long taskId)
{
Long userId = SecurityUtils.getUserId();
return success(aiGenerationTaskService.cancelTask(userId, projectId, taskId));
}
@GetMapping("/{projectId}/generations")
public AjaxResult generations(@PathVariable Long projectId)
{
Long userId = SecurityUtils.getUserId();
return success(aiGenerationTaskService.listGenerations(userId, projectId));
}
@GetMapping("/ai-quota")
public AjaxResult aiQuota()
{
Long userId = SecurityUtils.getUserId();
return success(aiGenerationTaskService.getQuota(userId));
}
@PostMapping("/{projectId}/preview")
public AjaxResult preview(@PathVariable Long projectId)
{

View File

@@ -0,0 +1,89 @@
package com.ruoyi.generator.domain.front;
import java.util.Date;
import com.ruoyi.common.core.domain.BaseEntity;
public class AiGenerationTask extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long taskId;
private Long generationId;
private Long projectId;
private Long userId;
private String generateType;
private String provider;
private String model;
private String status;
private String requestHash;
private String requestPayload;
private String resultPayload;
private String errorCode;
private String errorMessage;
private Integer attempts;
private Integer maxAttempts;
private Date nextRetryTime;
private String lockedBy;
private Date lockedUntil;
private Integer progress;
private String currentStep;
private Integer inputTokens;
private Integer outputTokens;
private Integer totalTokens;
private Integer costCents;
private Date startedAt;
private Date finishedAt;
public Long getTaskId() { return taskId; }
public void setTaskId(Long taskId) { this.taskId = taskId; }
public Long getGenerationId() { return generationId; }
public void setGenerationId(Long generationId) { this.generationId = generationId; }
public Long getProjectId() { return projectId; }
public void setProjectId(Long projectId) { this.projectId = projectId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getGenerateType() { return generateType; }
public void setGenerateType(String generateType) { this.generateType = generateType; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getRequestHash() { return requestHash; }
public void setRequestHash(String requestHash) { this.requestHash = requestHash; }
public String getRequestPayload() { return requestPayload; }
public void setRequestPayload(String requestPayload) { this.requestPayload = requestPayload; }
public String getResultPayload() { return resultPayload; }
public void setResultPayload(String resultPayload) { this.resultPayload = resultPayload; }
public String getErrorCode() { return errorCode; }
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Integer getAttempts() { return attempts; }
public void setAttempts(Integer attempts) { this.attempts = attempts; }
public Integer getMaxAttempts() { return maxAttempts; }
public void setMaxAttempts(Integer maxAttempts) { this.maxAttempts = maxAttempts; }
public Date getNextRetryTime() { return nextRetryTime; }
public void setNextRetryTime(Date nextRetryTime) { this.nextRetryTime = nextRetryTime; }
public String getLockedBy() { return lockedBy; }
public void setLockedBy(String lockedBy) { this.lockedBy = lockedBy; }
public Date getLockedUntil() { return lockedUntil; }
public void setLockedUntil(Date lockedUntil) { this.lockedUntil = lockedUntil; }
public Integer getProgress() { return progress; }
public void setProgress(Integer progress) { this.progress = progress; }
public String getCurrentStep() { return currentStep; }
public void setCurrentStep(String currentStep) { this.currentStep = currentStep; }
public Integer getInputTokens() { return inputTokens; }
public void setInputTokens(Integer inputTokens) { this.inputTokens = inputTokens; }
public Integer getOutputTokens() { return outputTokens; }
public void setOutputTokens(Integer outputTokens) { this.outputTokens = outputTokens; }
public Integer getTotalTokens() { return totalTokens; }
public void setTotalTokens(Integer totalTokens) { this.totalTokens = totalTokens; }
public Integer getCostCents() { return costCents; }
public void setCostCents(Integer costCents) { this.costCents = costCents; }
public Date getStartedAt() { return startedAt; }
public void setStartedAt(Date startedAt) { this.startedAt = startedAt; }
public Date getFinishedAt() { return finishedAt; }
public void setFinishedAt(Date finishedAt) { this.finishedAt = finishedAt; }
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.generator.domain.front;
import com.ruoyi.common.core.domain.BaseEntity;
public class AiQuotaBucket extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long bucketId;
private Long userId;
private String periodType;
private String periodKey;
private Integer taskLimit;
private Integer taskUsed;
private Integer tokenLimit;
private Integer tokenUsed;
private Integer costLimitCents;
private Integer costUsedCents;
private Integer runningLimit;
private Integer runningCount;
public Long getBucketId() { return bucketId; }
public void setBucketId(Long bucketId) { this.bucketId = bucketId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getPeriodType() { return periodType; }
public void setPeriodType(String periodType) { this.periodType = periodType; }
public String getPeriodKey() { return periodKey; }
public void setPeriodKey(String periodKey) { this.periodKey = periodKey; }
public Integer getTaskLimit() { return taskLimit; }
public void setTaskLimit(Integer taskLimit) { this.taskLimit = taskLimit; }
public Integer getTaskUsed() { return taskUsed; }
public void setTaskUsed(Integer taskUsed) { this.taskUsed = taskUsed; }
public Integer getTokenLimit() { return tokenLimit; }
public void setTokenLimit(Integer tokenLimit) { this.tokenLimit = tokenLimit; }
public Integer getTokenUsed() { return tokenUsed; }
public void setTokenUsed(Integer tokenUsed) { this.tokenUsed = tokenUsed; }
public Integer getCostLimitCents() { return costLimitCents; }
public void setCostLimitCents(Integer costLimitCents) { this.costLimitCents = costLimitCents; }
public Integer getCostUsedCents() { return costUsedCents; }
public void setCostUsedCents(Integer costUsedCents) { this.costUsedCents = costUsedCents; }
public Integer getRunningLimit() { return runningLimit; }
public void setRunningLimit(Integer runningLimit) { this.runningLimit = runningLimit; }
public Integer getRunningCount() { return runningCount; }
public void setRunningCount(Integer runningCount) { this.runningCount = runningCount; }
}

View File

@@ -0,0 +1,52 @@
package com.ruoyi.generator.domain.front;
import com.ruoyi.common.core.domain.BaseEntity;
public class AiUsageLedger extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long ledgerId;
private Long taskId;
private Long generationId;
private Long projectId;
private Long userId;
private String generateType;
private String provider;
private String model;
private Integer inputTokens;
private Integer outputTokens;
private Integer totalTokens;
private Integer costCents;
private String success;
private String errorMessage;
public Long getLedgerId() { return ledgerId; }
public void setLedgerId(Long ledgerId) { this.ledgerId = ledgerId; }
public Long getTaskId() { return taskId; }
public void setTaskId(Long taskId) { this.taskId = taskId; }
public Long getGenerationId() { return generationId; }
public void setGenerationId(Long generationId) { this.generationId = generationId; }
public Long getProjectId() { return projectId; }
public void setProjectId(Long projectId) { this.projectId = projectId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getGenerateType() { return generateType; }
public void setGenerateType(String generateType) { this.generateType = generateType; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public Integer getInputTokens() { return inputTokens; }
public void setInputTokens(Integer inputTokens) { this.inputTokens = inputTokens; }
public Integer getOutputTokens() { return outputTokens; }
public void setOutputTokens(Integer outputTokens) { this.outputTokens = outputTokens; }
public Integer getTotalTokens() { return totalTokens; }
public void setTotalTokens(Integer totalTokens) { this.totalTokens = totalTokens; }
public Integer getCostCents() { return costCents; }
public void setCostCents(Integer costCents) { this.costCents = costCents; }
public String getSuccess() { return success; }
public void setSuccess(String success) { this.success = success; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.generator.domain.front.dto;
public class AiGenerationTaskCreateRequest
{
private String generateType;
private String projectName;
private String projectDesc;
private String industryTemplate;
private String extraRequirements;
private String appBlueprint;
public String getGenerateType() { return generateType; }
public void setGenerateType(String generateType) { this.generateType = generateType; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getProjectDesc() { return projectDesc; }
public void setProjectDesc(String projectDesc) { this.projectDesc = projectDesc; }
public String getIndustryTemplate() { return industryTemplate; }
public void setIndustryTemplate(String industryTemplate) { this.industryTemplate = industryTemplate; }
public String getExtraRequirements() { return extraRequirements; }
public void setExtraRequirements(String extraRequirements) { this.extraRequirements = extraRequirements; }
public String getAppBlueprint() { return appBlueprint; }
public void setAppBlueprint(String appBlueprint) { this.appBlueprint = appBlueprint; }
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.generator.domain.front.dto;
import java.util.Date;
public class AiGenerationTaskStatusResponse
{
private Long taskId;
private Long projectId;
private Long generationId;
private String generateType;
private String status;
private Integer progress;
private String currentStep;
private String resultPayload;
private String errorMessage;
private Integer attempts;
private Integer maxAttempts;
private Date nextRetryTime;
private Date createTime;
private Date updateTime;
public Long getTaskId() { return taskId; }
public void setTaskId(Long taskId) { this.taskId = taskId; }
public Long getProjectId() { return projectId; }
public void setProjectId(Long projectId) { this.projectId = projectId; }
public Long getGenerationId() { return generationId; }
public void setGenerationId(Long generationId) { this.generationId = generationId; }
public String getGenerateType() { return generateType; }
public void setGenerateType(String generateType) { this.generateType = generateType; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Integer getProgress() { return progress; }
public void setProgress(Integer progress) { this.progress = progress; }
public String getCurrentStep() { return currentStep; }
public void setCurrentStep(String currentStep) { this.currentStep = currentStep; }
public String getResultPayload() { return resultPayload; }
public void setResultPayload(String resultPayload) { this.resultPayload = resultPayload; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Integer getAttempts() { return attempts; }
public void setAttempts(Integer attempts) { this.attempts = attempts; }
public Integer getMaxAttempts() { return maxAttempts; }
public void setMaxAttempts(Integer maxAttempts) { this.maxAttempts = maxAttempts; }
public Date getNextRetryTime() { return nextRetryTime; }
public void setNextRetryTime(Date nextRetryTime) { this.nextRetryTime = nextRetryTime; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.generator.domain.front.dto;
public class AiQuotaResponse
{
private Integer dailyTaskLimit;
private Integer dailyTaskUsed;
private Integer monthlyCostLimitCents;
private Integer monthlyCostUsedCents;
private Integer runningLimit;
private Integer runningCount;
public Integer getDailyTaskLimit() { return dailyTaskLimit; }
public void setDailyTaskLimit(Integer dailyTaskLimit) { this.dailyTaskLimit = dailyTaskLimit; }
public Integer getDailyTaskUsed() { return dailyTaskUsed; }
public void setDailyTaskUsed(Integer dailyTaskUsed) { this.dailyTaskUsed = dailyTaskUsed; }
public Integer getMonthlyCostLimitCents() { return monthlyCostLimitCents; }
public void setMonthlyCostLimitCents(Integer monthlyCostLimitCents) { this.monthlyCostLimitCents = monthlyCostLimitCents; }
public Integer getMonthlyCostUsedCents() { return monthlyCostUsedCents; }
public void setMonthlyCostUsedCents(Integer monthlyCostUsedCents) { this.monthlyCostUsedCents = monthlyCostUsedCents; }
public Integer getRunningLimit() { return runningLimit; }
public void setRunningLimit(Integer runningLimit) { this.runningLimit = runningLimit; }
public Integer getRunningCount() { return runningCount; }
public void setRunningCount(Integer runningCount) { this.runningCount = runningCount; }
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.generator.mapper.front;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.generator.domain.front.AiGenerationTask;
public interface AiGenerationTaskMapper
{
public AiGenerationTask selectAiGenerationTaskById(Long taskId);
public AiGenerationTask selectTaskForUser(@Param("userId") Long userId, @Param("projectId") Long projectId, @Param("taskId") Long taskId);
public AiGenerationTask selectActiveTaskByRequestHash(@Param("userId") Long userId, @Param("projectId") Long projectId, @Param("requestHash") String requestHash);
public List<AiGenerationTask> selectTasksByProjectId(@Param("userId") Long userId, @Param("projectId") Long projectId);
public List<AiGenerationTask> selectClaimableTasks(@Param("limit") Integer limit);
public int insertAiGenerationTask(AiGenerationTask aiGenerationTask);
public int updateAiGenerationTask(AiGenerationTask aiGenerationTask);
public int claimTask(@Param("taskId") Long taskId, @Param("lockedBy") String lockedBy);
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.generator.mapper.front;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.generator.domain.front.AiQuotaBucket;
public interface AiQuotaBucketMapper
{
public AiQuotaBucket selectQuotaBucket(@Param("userId") Long userId, @Param("periodType") String periodType, @Param("periodKey") String periodKey);
public int insertQuotaBucket(AiQuotaBucket aiQuotaBucket);
public int updateQuotaBucket(AiQuotaBucket aiQuotaBucket);
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.generator.mapper.front;
import com.ruoyi.generator.domain.front.AiUsageLedger;
public interface AiUsageLedgerMapper
{
public int insertAiUsageLedger(AiUsageLedger aiUsageLedger);
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.generator.service.front;
import org.springframework.stereotype.Component;
@Component
public class AiCostCalculator
{
private static final double DEEPSEEK_CHAT_INPUT_CENTS_PER_1K = 0.1D;
private static final double DEEPSEEK_CHAT_OUTPUT_CENTS_PER_1K = 0.6D;
public int estimateCostCents(String provider, String model, Integer inputTokens, Integer outputTokens)
{
if (inputTokens == null && outputTokens == null)
{
return 0;
}
int input = inputTokens == null ? 0 : Math.max(inputTokens, 0);
int output = outputTokens == null ? 0 : Math.max(outputTokens, 0);
if (input == 0 && output == 0)
{
return 0;
}
double cents = input / 1000D * DEEPSEEK_CHAT_INPUT_CENTS_PER_1K
+ output / 1000D * DEEPSEEK_CHAT_OUTPUT_CENTS_PER_1K;
return Math.max(1, (int) Math.ceil(cents));
}
}

View File

@@ -0,0 +1,222 @@
package com.ruoyi.generator.service.front;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.exception.ServiceException;
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.FrontProjectGeneration;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskStatusResponse;
import com.ruoyi.generator.domain.front.dto.AiQuotaResponse;
import com.ruoyi.generator.mapper.front.AiGenerationTaskMapper;
import com.ruoyi.generator.mapper.front.FrontProjectGenerationMapper;
import com.ruoyi.generator.mapper.front.FrontProjectMapper;
@Service
public class AiGenerationTaskServiceImpl implements IAiGenerationTaskService
{
private static final int MAX_ATTEMPTS = 3;
@Autowired
private FrontProjectMapper frontProjectMapper;
@Autowired
private AiGenerationTaskMapper aiGenerationTaskMapper;
@Autowired(required = false)
private FrontProjectGenerationMapper frontProjectGenerationMapper;
@Autowired
private AiQuotaService aiQuotaService;
@Autowired(required = false)
private AiGenerationTaskWorker aiGenerationTaskWorker;
@Autowired(required = false)
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Override
@Transactional
public AiGenerationTaskStatusResponse createTask(Long userId, Long projectId, AiGenerationTaskCreateRequest request)
{
assertOwnedProject(userId, projectId);
validateRequest(request);
String requestPayload = JSON.toJSONString(request);
String requestHash = sha256(request.getGenerateType() + ":" + requestPayload);
AiGenerationTask existing = aiGenerationTaskMapper.selectActiveTaskByRequestHash(userId, projectId, requestHash);
if (existing != null)
{
return toResponse(existing);
}
aiQuotaService.reserve(userId);
AiGenerationTask task = new AiGenerationTask();
task.setProjectId(projectId);
task.setUserId(userId);
task.setGenerateType(request.getGenerateType());
task.setProvider("deepseek");
task.setModel("deepseek-chat");
task.setStatus("QUEUED");
task.setRequestHash(requestHash);
task.setRequestPayload(requestPayload);
task.setAttempts(0);
task.setMaxAttempts(MAX_ATTEMPTS);
task.setProgress(0);
task.setCurrentStep("等待生成");
task.setNextRetryTime(new Date());
aiGenerationTaskMapper.insertAiGenerationTask(task);
dispatch(task.getTaskId());
return toResponse(task);
}
@Override
public AiGenerationTaskStatusResponse getTask(Long userId, Long projectId, Long taskId)
{
AiGenerationTask task = selectOwnedTask(userId, projectId, taskId);
return toResponse(task);
}
@Override
@Transactional
public AiGenerationTaskStatusResponse retryTask(Long userId, Long projectId, Long taskId)
{
AiGenerationTask task = selectOwnedTask(userId, projectId, taskId);
if (!"FAILED".equals(task.getStatus()) && !"RETRY_WAITING".equals(task.getStatus()))
{
throw new ServiceException("当前任务状态不允许重试");
}
aiQuotaService.reserve(userId);
task.setStatus("QUEUED");
task.setProgress(0);
task.setCurrentStep("等待重试");
task.setErrorCode("");
task.setErrorMessage("");
task.setNextRetryTime(new Date());
aiGenerationTaskMapper.updateAiGenerationTask(task);
dispatch(taskId);
return toResponse(task);
}
@Override
@Transactional
public AiGenerationTaskStatusResponse cancelTask(Long userId, Long projectId, Long taskId)
{
AiGenerationTask task = selectOwnedTask(userId, projectId, taskId);
if (!"QUEUED".equals(task.getStatus()) && !"RETRY_WAITING".equals(task.getStatus()))
{
throw new ServiceException("当前任务状态不允许取消");
}
task.setStatus("CANCELED");
task.setProgress(0);
task.setCurrentStep("已取消");
task.setFinishedAt(new Date());
aiGenerationTaskMapper.updateAiGenerationTask(task);
aiQuotaService.releaseRunning(userId);
return toResponse(task);
}
@Override
public List<FrontProjectGeneration> listGenerations(Long userId, Long projectId)
{
assertOwnedProject(userId, projectId);
return frontProjectGenerationMapper.selectGenerationsByProjectId(projectId);
}
@Override
public AiQuotaResponse getQuota(Long userId)
{
return aiQuotaService.getQuota(userId);
}
private FrontProject assertOwnedProject(Long userId, Long projectId)
{
FrontProject project = frontProjectMapper.selectFrontProjectByUserAndId(userId, projectId);
if (project == null)
{
throw new ServiceException("项目不存在或无权限");
}
return project;
}
private AiGenerationTask selectOwnedTask(Long userId, Long projectId, Long taskId)
{
AiGenerationTask task = aiGenerationTaskMapper.selectTaskForUser(userId, projectId, taskId);
if (task == null)
{
throw new ServiceException("任务不存在或无权限");
}
return task;
}
private void validateRequest(AiGenerationTaskCreateRequest request)
{
if (request == null || StringUtils.isEmpty(request.getGenerateType()))
{
throw new ServiceException("生成类型不能为空");
}
if (!"app_blueprint".equals(request.getGenerateType()) && !"database".equals(request.getGenerateType())
&& !"business_blueprint".equals(request.getGenerateType()))
{
throw new ServiceException("不支持的生成类型");
}
}
private AiGenerationTaskStatusResponse toResponse(AiGenerationTask task)
{
AiGenerationTaskStatusResponse response = new AiGenerationTaskStatusResponse();
response.setTaskId(task.getTaskId());
response.setProjectId(task.getProjectId());
response.setGenerationId(task.getGenerationId());
response.setGenerateType(task.getGenerateType());
response.setStatus(task.getStatus());
response.setProgress(task.getProgress());
response.setCurrentStep(task.getCurrentStep());
response.setResultPayload(task.getResultPayload());
response.setErrorMessage(task.getErrorMessage());
response.setAttempts(task.getAttempts());
response.setMaxAttempts(task.getMaxAttempts());
response.setNextRetryTime(task.getNextRetryTime());
response.setCreateTime(task.getCreateTime());
response.setUpdateTime(task.getUpdateTime());
return response;
}
private String sha256(String value)
{
try
{
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder();
for (byte b : hash)
{
builder.append(String.format("%02x", b));
}
return builder.toString();
}
catch (NoSuchAlgorithmException e)
{
throw new ServiceException("生成请求指纹失败").setDetailMessage(e.getMessage());
}
}
private void dispatch(final Long taskId)
{
if (taskId == null || aiGenerationTaskWorker == null || threadPoolTaskExecutor == null)
{
return;
}
threadPoolTaskExecutor.execute(new Runnable()
{
@Override
public void run()
{
aiGenerationTaskWorker.processTask(taskId);
}
});
}
}

View File

@@ -0,0 +1,243 @@
package com.ruoyi.generator.service.front;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.front.AiGenerationTask;
import com.ruoyi.generator.domain.front.AiUsageLedger;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.GenerateAppBlueprintRequest;
import com.ruoyi.generator.domain.front.dto.GenerateBusinessBlueprintRequest;
import com.ruoyi.generator.domain.front.dto.GenerateDatabaseRequest;
import com.ruoyi.generator.mapper.front.AiGenerationTaskMapper;
import com.ruoyi.generator.mapper.front.AiUsageLedgerMapper;
@Service
public class AiGenerationTaskWorker
{
@Autowired
private AiGenerationTaskMapper aiGenerationTaskMapper;
@Autowired(required = false)
private AiUsageLedgerMapper aiUsageLedgerMapper;
@Autowired
private IAiGenerateService aiGenerateService;
@Autowired
private AiQuotaService aiQuotaService;
@Autowired
private AiTaskRetryPolicy retryPolicy;
@Autowired
private AiCostCalculator costCalculator;
@Autowired(required = false)
private ScheduledExecutorService scheduledExecutorService;
@PostConstruct
public void startPolling()
{
if (scheduledExecutorService == null)
{
return;
}
scheduledExecutorService.scheduleWithFixedDelay(new Runnable()
{
@Override
public void run()
{
processDueTasks(5);
}
}, 10, 10, TimeUnit.SECONDS);
}
public void processTask(Long taskId)
{
AiGenerationTask task = aiGenerationTaskMapper.selectAiGenerationTaskById(taskId);
if (task == null)
{
return;
}
if (aiGenerationTaskMapper.claimTask(taskId, workerId()) == 0)
{
return;
}
int attempts = safe(task.getAttempts()) + 1;
task.setAttempts(attempts);
task.setStatus("RUNNING");
task.setProgress(10);
task.setCurrentStep("正在调用 AI");
task.setStartedAt(new Date());
aiGenerationTaskMapper.updateAiGenerationTask(task);
try
{
Object result = execute(task);
task.setStatus("SUCCEEDED");
task.setProgress(100);
task.setCurrentStep("生成完成");
task.setResultPayload(JSON.toJSONString(result));
task.setErrorCode("");
task.setErrorMessage("");
task.setFinishedAt(new Date());
task.setInputTokens(estimateTokens(task.getRequestPayload()));
task.setOutputTokens(estimateTokens(task.getResultPayload()));
task.setTotalTokens(safe(task.getInputTokens()) + safe(task.getOutputTokens()));
int totalTokens = safe(task.getTotalTokens());
int costCents = costCalculator.estimateCostCents(task.getProvider(), task.getModel(), task.getInputTokens(), task.getOutputTokens());
task.setCostCents(costCents);
aiGenerationTaskMapper.updateAiGenerationTask(task);
insertLedger(task, "1");
aiQuotaService.settleCost(task.getUserId(), totalTokens, costCents);
}
catch (RuntimeException e)
{
String message = sanitize(e.getMessage());
task.setErrorMessage(message);
task.setProgress(0);
if (retryPolicy.canRetry(message, attempts, safeMax(task.getMaxAttempts())))
{
task.setStatus("RETRY_WAITING");
task.setCurrentStep("等待重试");
task.setNextRetryTime(retryPolicy.nextRetryTime(attempts, new Date()));
}
else
{
task.setStatus("FAILED");
task.setCurrentStep("生成失败");
task.setFinishedAt(new Date());
}
aiGenerationTaskMapper.updateAiGenerationTask(task);
insertLedger(task, "0");
}
finally
{
aiQuotaService.releaseRunning(task.getUserId());
}
}
public int processDueTasks(int limit)
{
List<AiGenerationTask> tasks = aiGenerationTaskMapper.selectClaimableTasks(limit);
if (tasks == null || tasks.isEmpty())
{
return 0;
}
int count = 0;
for (AiGenerationTask task : tasks)
{
processTask(task.getTaskId());
count++;
}
return count;
}
private Object execute(AiGenerationTask task)
{
AiGenerationTaskCreateRequest request = JSON.parseObject(task.getRequestPayload(), AiGenerationTaskCreateRequest.class);
if ("app_blueprint".equals(task.getGenerateType()))
{
return aiGenerateService.generateAppBlueprint(task.getUserId(), task.getProjectId(), toAppBlueprintRequest(request));
}
if ("database".equals(task.getGenerateType()))
{
return aiGenerateService.generateDatabase(task.getUserId(), task.getProjectId(), toDatabaseRequest(request));
}
if ("business_blueprint".equals(task.getGenerateType()))
{
return aiGenerateService.generateBusinessBlueprint(task.getUserId(), task.getProjectId(), toBusinessBlueprintRequest(request));
}
throw new IllegalArgumentException("不支持的生成类型");
}
private GenerateAppBlueprintRequest toAppBlueprintRequest(AiGenerationTaskCreateRequest source)
{
GenerateAppBlueprintRequest request = new GenerateAppBlueprintRequest();
request.setProjectName(source.getProjectName());
request.setProjectDesc(source.getProjectDesc());
request.setIndustryTemplate(source.getIndustryTemplate());
request.setExtraRequirements(source.getExtraRequirements());
return request;
}
private GenerateDatabaseRequest toDatabaseRequest(AiGenerationTaskCreateRequest source)
{
GenerateDatabaseRequest request = new GenerateDatabaseRequest();
request.setProjectName(source.getProjectName());
request.setProjectDesc(source.getProjectDesc());
request.setIndustryTemplate(source.getIndustryTemplate());
request.setExtraRequirements(source.getExtraRequirements());
request.setAppBlueprint(source.getAppBlueprint());
return request;
}
private GenerateBusinessBlueprintRequest toBusinessBlueprintRequest(AiGenerationTaskCreateRequest source)
{
GenerateBusinessBlueprintRequest request = new GenerateBusinessBlueprintRequest();
request.setProjectName(source.getProjectName());
request.setProjectDesc(source.getProjectDesc());
request.setIndustryTemplate(source.getIndustryTemplate());
request.setExtraRequirements(source.getExtraRequirements());
return request;
}
private void insertLedger(AiGenerationTask task, String success)
{
if (aiUsageLedgerMapper == null)
{
return;
}
AiUsageLedger ledger = new AiUsageLedger();
ledger.setTaskId(task.getTaskId());
ledger.setGenerationId(task.getGenerationId());
ledger.setProjectId(task.getProjectId());
ledger.setUserId(task.getUserId());
ledger.setGenerateType(task.getGenerateType());
ledger.setProvider(task.getProvider());
ledger.setModel(task.getModel());
ledger.setInputTokens(task.getInputTokens());
ledger.setOutputTokens(task.getOutputTokens());
ledger.setTotalTokens(task.getTotalTokens());
ledger.setCostCents(task.getCostCents());
ledger.setSuccess(success);
ledger.setErrorMessage(task.getErrorMessage());
aiUsageLedgerMapper.insertAiUsageLedger(ledger);
}
private String workerId()
{
return "ai-task-worker-" + UUID.randomUUID().toString();
}
private int safe(Integer value)
{
return value == null ? 0 : value;
}
private int estimateTokens(String text)
{
if (StringUtils.isEmpty(text))
{
return 0;
}
return Math.max(1, (text.length() + 3) / 4);
}
private int safeMax(Integer value)
{
return value == null || value <= 0 ? 3 : value;
}
private String sanitize(String message)
{
if (StringUtils.isEmpty(message))
{
return "";
}
String sanitized = message.replaceAll("[\\p{Cntrl}]", " ");
return sanitized.length() > 1000 ? sanitized.substring(0, 1000) : sanitized;
}
}

View File

@@ -0,0 +1,127 @@
package com.ruoyi.generator.service.front;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.domain.front.AiQuotaBucket;
import com.ruoyi.generator.domain.front.dto.AiQuotaResponse;
import com.ruoyi.generator.mapper.front.AiQuotaBucketMapper;
@Service
public class AiQuotaService
{
private static final int DAILY_TASK_LIMIT = 20;
private static final int MONTHLY_COST_LIMIT_CENTS = 5000;
private static final int RUNNING_LIMIT = 1;
@Autowired(required = false)
private AiQuotaBucketMapper aiQuotaBucketMapper;
@Transactional
public void reserve(Long userId)
{
if (aiQuotaBucketMapper == null)
{
return;
}
AiQuotaBucket day = ensureBucket(userId, "DAY", new SimpleDateFormat("yyyyMMdd").format(new Date()));
AiQuotaBucket month = ensureBucket(userId, "MONTH", new SimpleDateFormat("yyyyMM").format(new Date()));
if (safe(day.getTaskUsed()) >= safe(day.getTaskLimit()))
{
throw new ServiceException("今日 AI 生成次数已用完");
}
if (safe(month.getCostUsedCents()) >= safe(month.getCostLimitCents()))
{
throw new ServiceException("本月 AI 生成成本配额已用完");
}
if (safe(day.getRunningCount()) >= safe(day.getRunningLimit()))
{
throw new ServiceException("已有 AI 生成任务正在执行,请稍后再试");
}
day.setTaskUsed(safe(day.getTaskUsed()) + 1);
day.setRunningCount(safe(day.getRunningCount()) + 1);
aiQuotaBucketMapper.updateQuotaBucket(day);
}
@Transactional
public void releaseRunning(Long userId)
{
if (aiQuotaBucketMapper == null)
{
return;
}
AiQuotaBucket day = ensureBucket(userId, "DAY", new SimpleDateFormat("yyyyMMdd").format(new Date()));
day.setRunningCount(Math.max(0, safe(day.getRunningCount()) - 1));
aiQuotaBucketMapper.updateQuotaBucket(day);
}
@Transactional
public void settleCost(Long userId, int totalTokens, int costCents)
{
if (aiQuotaBucketMapper == null)
{
return;
}
AiQuotaBucket month = ensureBucket(userId, "MONTH", new SimpleDateFormat("yyyyMM").format(new Date()));
month.setTokenUsed(safe(month.getTokenUsed()) + Math.max(totalTokens, 0));
month.setCostUsedCents(safe(month.getCostUsedCents()) + Math.max(costCents, 0));
aiQuotaBucketMapper.updateQuotaBucket(month);
}
public AiQuotaResponse getQuota(Long userId)
{
AiQuotaResponse response = new AiQuotaResponse();
response.setDailyTaskLimit(DAILY_TASK_LIMIT);
response.setDailyTaskUsed(0);
response.setMonthlyCostLimitCents(MONTHLY_COST_LIMIT_CENTS);
response.setMonthlyCostUsedCents(0);
response.setRunningLimit(RUNNING_LIMIT);
response.setRunningCount(0);
if (aiQuotaBucketMapper != null)
{
AiQuotaBucket day = aiQuotaBucketMapper.selectQuotaBucket(userId, "DAY", new SimpleDateFormat("yyyyMMdd").format(new Date()));
AiQuotaBucket month = aiQuotaBucketMapper.selectQuotaBucket(userId, "MONTH", new SimpleDateFormat("yyyyMM").format(new Date()));
if (day != null)
{
response.setDailyTaskUsed(safe(day.getTaskUsed()));
response.setRunningCount(safe(day.getRunningCount()));
}
if (month != null)
{
response.setMonthlyCostUsedCents(safe(month.getCostUsedCents()));
}
}
return response;
}
private AiQuotaBucket ensureBucket(Long userId, String periodType, String periodKey)
{
AiQuotaBucket bucket = aiQuotaBucketMapper.selectQuotaBucket(userId, periodType, periodKey);
if (bucket != null)
{
return bucket;
}
bucket = new AiQuotaBucket();
bucket.setUserId(userId);
bucket.setPeriodType(periodType);
bucket.setPeriodKey(periodKey);
bucket.setTaskLimit("DAY".equals(periodType) ? DAILY_TASK_LIMIT : 0);
bucket.setTaskUsed(0);
bucket.setTokenLimit(0);
bucket.setTokenUsed(0);
bucket.setCostLimitCents("MONTH".equals(periodType) ? MONTHLY_COST_LIMIT_CENTS : 0);
bucket.setCostUsedCents(0);
bucket.setRunningLimit("DAY".equals(periodType) ? RUNNING_LIMIT : 0);
bucket.setRunningCount(0);
aiQuotaBucketMapper.insertQuotaBucket(bucket);
return bucket;
}
private int safe(Integer value)
{
return value == null ? 0 : value;
}
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.generator.service.front;
import java.util.Date;
import java.util.Locale;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.StringUtils;
@Component
public class AiTaskRetryPolicy
{
private static final long[] RETRY_DELAYS_MS = new long[] { 30_000L, 120_000L, 300_000L };
public boolean canRetry(String errorMessage, int attempts, int maxAttempts)
{
if (attempts >= maxAttempts)
{
return false;
}
if (StringUtils.isEmpty(errorMessage))
{
return true;
}
String message = errorMessage.toLowerCase(Locale.ROOT);
if (message.contains("配额") || message.contains("quota") || message.contains("无权限")
|| message.contains("permission") || message.contains("missing field")
|| message.contains("validation"))
{
return false;
}
return message.contains("timeout") || message.contains("timed out") || message.contains("429")
|| message.contains("too many requests") || message.contains("unavailable")
|| message.contains("502") || message.contains("503") || message.contains("504")
|| message.contains("5xx");
}
public Date nextRetryTime(int attempts, Date now)
{
long delay = RETRY_DELAYS_MS[Math.min(Math.max(attempts, 1), RETRY_DELAYS_MS.length) - 1];
return new Date(now.getTime() + delay);
}
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.generator.service.front;
import java.util.List;
import com.ruoyi.generator.domain.front.FrontProjectGeneration;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskStatusResponse;
import com.ruoyi.generator.domain.front.dto.AiQuotaResponse;
public interface IAiGenerationTaskService
{
AiGenerationTaskStatusResponse createTask(Long userId, Long projectId, AiGenerationTaskCreateRequest request);
AiGenerationTaskStatusResponse getTask(Long userId, Long projectId, Long taskId);
AiGenerationTaskStatusResponse retryTask(Long userId, Long projectId, Long taskId);
AiGenerationTaskStatusResponse cancelTask(Long userId, Long projectId, Long taskId);
List<FrontProjectGeneration> listGenerations(Long userId, Long projectId);
AiQuotaResponse getQuota(Long userId);
}

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.generator.mapper.front.AiGenerationTaskMapper">
<resultMap type="AiGenerationTask" id="AiGenerationTaskResult">
<id property="taskId" column="task_id" />
<result property="generationId" column="generation_id" />
<result property="projectId" column="project_id" />
<result property="userId" column="user_id" />
<result property="generateType" column="generate_type" />
<result property="provider" column="provider" />
<result property="model" column="model" />
<result property="status" column="status" />
<result property="requestHash" column="request_hash" />
<result property="requestPayload" column="request_payload" />
<result property="resultPayload" column="result_payload" />
<result property="errorCode" column="error_code" />
<result property="errorMessage" column="error_message" />
<result property="attempts" column="attempts" />
<result property="maxAttempts" column="max_attempts" />
<result property="nextRetryTime" column="next_retry_time" />
<result property="lockedBy" column="locked_by" />
<result property="lockedUntil" column="locked_until" />
<result property="progress" column="progress" />
<result property="currentStep" column="current_step" />
<result property="inputTokens" column="input_tokens" />
<result property="outputTokens" column="output_tokens" />
<result property="totalTokens" column="total_tokens" />
<result property="costCents" column="cost_cents" />
<result property="startedAt" column="started_at" />
<result property="finishedAt" column="finished_at" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectAiGenerationTaskVo">
select task_id, generation_id, project_id, user_id, generate_type, provider, model, status,
request_hash, request_payload, result_payload, error_code, error_message, attempts, max_attempts,
next_retry_time, locked_by, locked_until, progress, current_step, input_tokens, output_tokens,
total_tokens, cost_cents, started_at, finished_at, create_time, update_time
from front_ai_generation_task
</sql>
<select id="selectAiGenerationTaskById" parameterType="Long" resultMap="AiGenerationTaskResult">
<include refid="selectAiGenerationTaskVo"/>
where task_id = #{taskId}
</select>
<select id="selectTaskForUser" resultMap="AiGenerationTaskResult">
<include refid="selectAiGenerationTaskVo"/>
where user_id = #{userId} and project_id = #{projectId} and task_id = #{taskId}
</select>
<select id="selectActiveTaskByRequestHash" resultMap="AiGenerationTaskResult">
<include refid="selectAiGenerationTaskVo"/>
where user_id = #{userId}
and project_id = #{projectId}
and request_hash = #{requestHash}
and status in ('QUEUED', 'RUNNING', 'RETRY_WAITING')
order by task_id desc
limit 1
</select>
<select id="selectTasksByProjectId" resultMap="AiGenerationTaskResult">
<include refid="selectAiGenerationTaskVo"/>
where user_id = #{userId} and project_id = #{projectId}
order by task_id desc
</select>
<select id="selectClaimableTasks" resultMap="AiGenerationTaskResult">
<include refid="selectAiGenerationTaskVo"/>
where status in ('QUEUED', 'RETRY_WAITING')
and (next_retry_time is null or next_retry_time &lt;= sysdate())
and (locked_until is null or locked_until &lt; sysdate())
order by task_id asc
limit #{limit}
</select>
<insert id="insertAiGenerationTask" parameterType="AiGenerationTask" useGeneratedKeys="true" keyProperty="taskId">
insert into front_ai_generation_task
<trim prefix="(" suffix=")" suffixOverrides=",">
generation_id, project_id, user_id, generate_type, provider, model, status, request_hash,
request_payload, result_payload, error_code, error_message, attempts, max_attempts,
next_retry_time, locked_by, locked_until, progress, current_step, input_tokens, output_tokens,
total_tokens, cost_cents, started_at, finished_at, create_time, update_time,
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
#{generationId}, #{projectId}, #{userId}, #{generateType}, #{provider}, #{model}, #{status}, #{requestHash},
#{requestPayload}, #{resultPayload}, #{errorCode}, #{errorMessage}, #{attempts}, #{maxAttempts},
#{nextRetryTime}, #{lockedBy}, #{lockedUntil}, #{progress}, #{currentStep}, #{inputTokens}, #{outputTokens},
#{totalTokens}, #{costCents}, #{startedAt}, #{finishedAt}, sysdate(), sysdate(),
</trim>
</insert>
<update id="updateAiGenerationTask" parameterType="AiGenerationTask">
update front_ai_generation_task
<trim prefix="SET" suffixOverrides=",">
<if test="generationId != null">generation_id = #{generationId},</if>
<if test="status != null">status = #{status},</if>
<if test="resultPayload != null">result_payload = #{resultPayload},</if>
<if test="errorCode != null">error_code = #{errorCode},</if>
<if test="errorMessage != null">error_message = #{errorMessage},</if>
<if test="attempts != null">attempts = #{attempts},</if>
<if test="maxAttempts != null">max_attempts = #{maxAttempts},</if>
<if test="nextRetryTime != null">next_retry_time = #{nextRetryTime},</if>
<if test="lockedBy != null">locked_by = #{lockedBy},</if>
<if test="lockedUntil != null">locked_until = #{lockedUntil},</if>
<if test="progress != null">progress = #{progress},</if>
<if test="currentStep != null">current_step = #{currentStep},</if>
<if test="inputTokens != null">input_tokens = #{inputTokens},</if>
<if test="outputTokens != null">output_tokens = #{outputTokens},</if>
<if test="totalTokens != null">total_tokens = #{totalTokens},</if>
<if test="costCents != null">cost_cents = #{costCents},</if>
<if test="startedAt != null">started_at = #{startedAt},</if>
<if test="finishedAt != null">finished_at = #{finishedAt},</if>
update_time = sysdate(),
</trim>
where task_id = #{taskId}
</update>
<update id="claimTask">
update front_ai_generation_task
set status = 'RUNNING',
locked_by = #{lockedBy},
locked_until = date_add(sysdate(), interval 10 minute),
current_step = '正在启动',
progress = 5,
update_time = sysdate()
where task_id = #{taskId}
and status in ('QUEUED', 'RETRY_WAITING')
and (next_retry_time is null or next_retry_time &lt;= sysdate())
and (locked_until is null or locked_until &lt; sysdate())
</update>
</mapper>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.generator.mapper.front.AiQuotaBucketMapper">
<resultMap type="AiQuotaBucket" id="AiQuotaBucketResult">
<id property="bucketId" column="bucket_id" />
<result property="userId" column="user_id" />
<result property="periodType" column="period_type" />
<result property="periodKey" column="period_key" />
<result property="taskLimit" column="task_limit" />
<result property="taskUsed" column="task_used" />
<result property="tokenLimit" column="token_limit" />
<result property="tokenUsed" column="token_used" />
<result property="costLimitCents" column="cost_limit_cents" />
<result property="costUsedCents" column="cost_used_cents" />
<result property="runningLimit" column="running_limit" />
<result property="runningCount" column="running_count" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<select id="selectQuotaBucket" resultMap="AiQuotaBucketResult">
select bucket_id, user_id, period_type, period_key, task_limit, task_used, token_limit, token_used,
cost_limit_cents, cost_used_cents, running_limit, running_count, create_time, update_time
from front_ai_quota_bucket
where user_id = #{userId} and period_type = #{periodType} and period_key = #{periodKey}
</select>
<insert id="insertQuotaBucket" parameterType="AiQuotaBucket" useGeneratedKeys="true" keyProperty="bucketId">
insert into front_ai_quota_bucket
(user_id, period_type, period_key, task_limit, task_used, token_limit, token_used,
cost_limit_cents, cost_used_cents, running_limit, running_count, create_time, update_time)
values
(#{userId}, #{periodType}, #{periodKey}, #{taskLimit}, #{taskUsed}, #{tokenLimit}, #{tokenUsed},
#{costLimitCents}, #{costUsedCents}, #{runningLimit}, #{runningCount}, sysdate(), sysdate())
</insert>
<update id="updateQuotaBucket" parameterType="AiQuotaBucket">
update front_ai_quota_bucket
set task_limit = #{taskLimit},
task_used = #{taskUsed},
token_limit = #{tokenLimit},
token_used = #{tokenUsed},
cost_limit_cents = #{costLimitCents},
cost_used_cents = #{costUsedCents},
running_limit = #{runningLimit},
running_count = #{runningCount},
update_time = sysdate()
where bucket_id = #{bucketId}
</update>
</mapper>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.generator.mapper.front.AiUsageLedgerMapper">
<insert id="insertAiUsageLedger" parameterType="AiUsageLedger" useGeneratedKeys="true" keyProperty="ledgerId">
insert into front_ai_usage_ledger
(task_id, generation_id, project_id, user_id, generate_type, provider, model, input_tokens,
output_tokens, total_tokens, cost_cents, success, error_message, create_time)
values
(#{taskId}, #{generationId}, #{projectId}, #{userId}, #{generateType}, #{provider}, #{model}, #{inputTokens},
#{outputTokens}, #{totalTokens}, #{costCents}, #{success}, #{errorMessage}, sysdate())
</insert>
</mapper>

View File

@@ -0,0 +1,26 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class AiCostCalculatorTest
{
@Test
public void missingTokensCostNothing()
{
AiCostCalculator calculator = new AiCostCalculator();
assertEquals(0, calculator.estimateCostCents("deepseek", "deepseek-chat", null, null));
assertEquals(0, calculator.estimateCostCents("deepseek", "deepseek-chat", 0, 0));
}
@Test
public void deepSeekChatUsesInputAndOutputRates()
{
AiCostCalculator calculator = new AiCostCalculator();
assertEquals(1, calculator.estimateCostCents("deepseek", "deepseek-chat", 1_000, 1_000));
assertEquals(3, calculator.estimateCostCents("deepseek", "deepseek-chat", 2_000, 4_000));
}
}

View File

@@ -0,0 +1,152 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import com.ruoyi.common.exception.ServiceException;
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.AiGenerationTaskStatusResponse;
import com.ruoyi.generator.mapper.front.AiGenerationTaskMapper;
import com.ruoyi.generator.mapper.front.FrontProjectMapper;
public class AiGenerationTaskServiceImplTest
{
private AiGenerationTaskServiceImpl service;
@Mock
private FrontProjectMapper frontProjectMapper;
@Mock
private AiGenerationTaskMapper aiGenerationTaskMapper;
@Mock
private AiQuotaService aiQuotaService;
@Before
public void setUp() throws Exception
{
MockitoAnnotations.initMocks(this);
service = new AiGenerationTaskServiceImpl();
setField("frontProjectMapper", frontProjectMapper);
setField("aiGenerationTaskMapper", aiGenerationTaskMapper);
setField("aiQuotaService", aiQuotaService);
}
@Test
public void createTaskQueuesDatabaseGenerationAndReservesQuota()
{
when(frontProjectMapper.selectFrontProjectByUserAndId(7L, 10L)).thenReturn(project());
org.mockito.Mockito.doAnswer(invocation -> {
AiGenerationTask task = invocation.getArgument(0);
task.setTaskId(88L);
return 1;
}).when(aiGenerationTaskMapper).insertAiGenerationTask(any(AiGenerationTask.class));
AiGenerationTaskStatusResponse response = service.createTask(7L, 10L, request("database"));
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
verify(aiQuotaService).reserve(7L);
verify(aiGenerationTaskMapper).insertAiGenerationTask(taskCaptor.capture());
AiGenerationTask task = taskCaptor.getValue();
assertEquals(Long.valueOf(10L), task.getProjectId());
assertEquals(Long.valueOf(7L), task.getUserId());
assertEquals("database", task.getGenerateType());
assertEquals("QUEUED", task.getStatus());
assertEquals(Integer.valueOf(0), task.getProgress());
assertEquals(Integer.valueOf(0), task.getAttempts());
assertEquals(Integer.valueOf(3), task.getMaxAttempts());
assertNotNull(task.getRequestHash());
assertEquals(Long.valueOf(88L), response.getTaskId());
assertEquals("QUEUED", response.getStatus());
}
@Test
public void createTaskReusesRunningTaskWithSameRequestHash()
{
when(frontProjectMapper.selectFrontProjectByUserAndId(7L, 10L)).thenReturn(project());
AiGenerationTask existing = new AiGenerationTask();
existing.setTaskId(99L);
existing.setProjectId(10L);
existing.setUserId(7L);
existing.setGenerateType("database");
existing.setStatus("RUNNING");
existing.setProgress(35);
when(aiGenerationTaskMapper.selectActiveTaskByRequestHash(anyLong(), anyLong(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(existing);
AiGenerationTaskStatusResponse response = service.createTask(7L, 10L, request("database"));
verify(aiQuotaService, never()).reserve(anyLong());
verify(aiGenerationTaskMapper, never()).insertAiGenerationTask(any(AiGenerationTask.class));
assertEquals(Long.valueOf(99L), response.getTaskId());
assertEquals("RUNNING", response.getStatus());
assertEquals(Integer.valueOf(35), response.getProgress());
}
@Test(expected = ServiceException.class)
public void createTaskRejectsMissingProjectOwnership()
{
when(frontProjectMapper.selectFrontProjectByUserAndId(7L, 10L)).thenReturn(null);
service.createTask(7L, 10L, request("database"));
}
@Test
public void getTaskReturnsOnlyOwnedTask()
{
AiGenerationTask task = new AiGenerationTask();
task.setTaskId(99L);
task.setProjectId(10L);
task.setUserId(7L);
task.setGenerateType("database");
task.setStatus("SUCCEEDED");
task.setResultPayload("{\"tables\":[]}");
when(aiGenerationTaskMapper.selectTaskForUser(7L, 10L, 99L)).thenReturn(task);
AiGenerationTaskStatusResponse response = service.getTask(7L, 10L, 99L);
assertSame(task.getTaskId(), response.getTaskId());
assertEquals("SUCCEEDED", response.getStatus());
assertEquals("{\"tables\":[]}", response.getResultPayload());
}
private AiGenerationTaskCreateRequest request(String generateType)
{
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
request.setGenerateType(generateType);
request.setProjectName("客户中心");
request.setProjectDesc("管理客户资料");
request.setIndustryTemplate("crm");
request.setExtraRequirements("客户分层");
request.setAppBlueprint("{\"roles\":[]}");
return request;
}
private FrontProject project()
{
FrontProject project = new FrontProject();
project.setProjectId(10L);
project.setUserId(7L);
project.setProjectName("客户中心");
return project;
}
private void setField(String name, Object value) throws Exception
{
Field field = AiGenerationTaskServiceImpl.class.getDeclaredField(name);
field.setAccessible(true);
field.set(service, value);
}
}

View File

@@ -0,0 +1,168 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.generator.domain.front.AiGenerationTask;
import com.ruoyi.generator.domain.front.AiUsageLedger;
import com.ruoyi.generator.domain.front.dto.AiGenerationTaskCreateRequest;
import com.ruoyi.generator.domain.front.dto.AppBlueprintDesign;
import com.ruoyi.generator.domain.front.dto.GenerateAppBlueprintRequest;
import com.ruoyi.generator.mapper.front.AiGenerationTaskMapper;
import com.ruoyi.generator.mapper.front.AiUsageLedgerMapper;
public class AiGenerationTaskWorkerTest
{
private AiGenerationTaskWorker worker;
@Mock
private AiGenerationTaskMapper aiGenerationTaskMapper;
@Mock
private AiUsageLedgerMapper aiUsageLedgerMapper;
@Mock
private IAiGenerateService aiGenerateService;
@Mock
private AiQuotaService aiQuotaService;
@Before
public void setUp() throws Exception
{
MockitoAnnotations.initMocks(this);
worker = new AiGenerationTaskWorker();
setField("aiGenerationTaskMapper", aiGenerationTaskMapper);
setField("aiUsageLedgerMapper", aiUsageLedgerMapper);
setField("aiGenerateService", aiGenerateService);
setField("aiQuotaService", aiQuotaService);
setField("retryPolicy", new AiTaskRetryPolicy());
setField("costCalculator", new AiCostCalculator());
}
@Test
public void processTaskMarksSuccessAndStoresResult()
{
AiGenerationTask task = task("app_blueprint");
when(aiGenerationTaskMapper.selectAiGenerationTaskById(88L)).thenReturn(task);
when(aiGenerationTaskMapper.claimTask(org.mockito.ArgumentMatchers.eq(88L), any(String.class))).thenReturn(1);
AppBlueprintDesign blueprint = new AppBlueprintDesign();
blueprint.setRoles(Collections.emptyList());
when(aiGenerateService.generateAppBlueprint(any(Long.class), any(Long.class), any(GenerateAppBlueprintRequest.class)))
.thenReturn(blueprint);
worker.processTask(88L);
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
verify(aiGenerationTaskMapper, org.mockito.Mockito.atLeastOnce()).updateAiGenerationTask(taskCaptor.capture());
AiGenerationTask saved = taskCaptor.getAllValues().get(taskCaptor.getAllValues().size() - 1);
assertEquals("SUCCEEDED", saved.getStatus());
assertEquals(Integer.valueOf(100), saved.getProgress());
assertTrue(saved.getTotalTokens() > 0);
assertTrue(saved.getCostCents() > 0);
assertTrue(saved.getResultPayload().contains("\"roles\":[]"));
verify(aiQuotaService).releaseRunning(7L);
verify(aiUsageLedgerMapper).insertAiUsageLedger(any(AiUsageLedger.class));
}
@Test
public void processTaskSchedulesRetryForRetryableFailure()
{
AiGenerationTask task = task("app_blueprint");
task.setAttempts(1);
when(aiGenerationTaskMapper.selectAiGenerationTaskById(88L)).thenReturn(task);
when(aiGenerationTaskMapper.claimTask(org.mockito.ArgumentMatchers.eq(88L), any(String.class))).thenReturn(1);
when(aiGenerateService.generateAppBlueprint(any(Long.class), any(Long.class), any(GenerateAppBlueprintRequest.class)))
.thenThrow(new RuntimeException("DeepSeek request timeout"));
worker.processTask(88L);
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
verify(aiGenerationTaskMapper, org.mockito.Mockito.atLeastOnce()).updateAiGenerationTask(taskCaptor.capture());
AiGenerationTask saved = taskCaptor.getAllValues().get(taskCaptor.getAllValues().size() - 1);
assertEquals("RETRY_WAITING", saved.getStatus());
assertEquals(Integer.valueOf(2), saved.getAttempts());
verify(aiQuotaService).releaseRunning(7L);
}
@Test
public void processTaskMarksFailedForNonRetryableFailure()
{
AiGenerationTask task = task("app_blueprint");
when(aiGenerationTaskMapper.selectAiGenerationTaskById(88L)).thenReturn(task);
when(aiGenerationTaskMapper.claimTask(org.mockito.ArgumentMatchers.eq(88L), any(String.class))).thenReturn(1);
when(aiGenerateService.generateAppBlueprint(any(Long.class), any(Long.class), any(GenerateAppBlueprintRequest.class)))
.thenThrow(new RuntimeException("配额不足"));
worker.processTask(88L);
ArgumentCaptor<AiGenerationTask> taskCaptor = ArgumentCaptor.forClass(AiGenerationTask.class);
verify(aiGenerationTaskMapper, org.mockito.Mockito.atLeastOnce()).updateAiGenerationTask(taskCaptor.capture());
AiGenerationTask saved = taskCaptor.getAllValues().get(taskCaptor.getAllValues().size() - 1);
assertEquals("FAILED", saved.getStatus());
assertEquals(Integer.valueOf(1), saved.getAttempts());
verify(aiQuotaService).releaseRunning(7L);
}
@Test
public void processDueTasksRunsClaimableTasks()
{
AiGenerationTask first = task("app_blueprint");
first.setTaskId(88L);
AiGenerationTask second = task("app_blueprint");
second.setTaskId(89L);
when(aiGenerationTaskMapper.selectClaimableTasks(2)).thenReturn(Arrays.asList(first, second));
when(aiGenerationTaskMapper.selectAiGenerationTaskById(88L)).thenReturn(first);
when(aiGenerationTaskMapper.selectAiGenerationTaskById(89L)).thenReturn(second);
when(aiGenerationTaskMapper.claimTask(any(Long.class), any(String.class))).thenReturn(1);
AppBlueprintDesign blueprint = new AppBlueprintDesign();
blueprint.setRoles(Collections.emptyList());
when(aiGenerateService.generateAppBlueprint(any(Long.class), any(Long.class), any(GenerateAppBlueprintRequest.class)))
.thenReturn(blueprint);
int count = worker.processDueTasks(2);
assertEquals(2, count);
verify(aiGenerationTaskMapper).selectClaimableTasks(2);
verify(aiGenerationTaskMapper).selectAiGenerationTaskById(88L);
verify(aiGenerationTaskMapper).selectAiGenerationTaskById(89L);
}
private AiGenerationTask task(String generateType)
{
AiGenerationTaskCreateRequest request = new AiGenerationTaskCreateRequest();
request.setGenerateType(generateType);
request.setProjectName("客户中心");
request.setProjectDesc("管理客户资料");
request.setIndustryTemplate("crm");
request.setExtraRequirements("客户分层");
AiGenerationTask task = new AiGenerationTask();
task.setTaskId(88L);
task.setProjectId(10L);
task.setUserId(7L);
task.setGenerateType(generateType);
task.setProvider("deepseek");
task.setModel("deepseek-chat");
task.setStatus("QUEUED");
task.setAttempts(0);
task.setMaxAttempts(3);
task.setRequestPayload(JSON.toJSONString(request));
return task;
}
private void setField(String name, Object value) throws Exception
{
Field field = AiGenerationTaskWorker.class.getDeclaredField(name);
field.setAccessible(true);
field.set(worker, value);
}
}

View File

@@ -0,0 +1,52 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
import java.util.Date;
import org.junit.Test;
public class AiTaskRetryPolicyTest
{
@Test
public void retryableFailuresCanRetryBeforeMaxAttempts()
{
AiTaskRetryPolicy policy = new AiTaskRetryPolicy();
assertTrue(policy.canRetry("DeepSeek request timeout", 1, 3));
assertTrue(policy.canRetry("429 too many requests", 1, 3));
assertTrue(policy.canRetry("DeepSeek unavailable", 2, 3));
assertTrue(policy.canRetry("HTTP 502 bad gateway", 2, 3));
}
@Test
public void nonRetryableFailuresDoNotRetry()
{
AiTaskRetryPolicy policy = new AiTaskRetryPolicy();
assertFalse(policy.canRetry("配额不足", 1, 3));
assertFalse(policy.canRetry("项目不存在或无权限", 1, 3));
assertFalse(policy.canRetry("Business action references missing field", 1, 3));
}
@Test
public void maxAttemptsStopsRetry()
{
AiTaskRetryPolicy policy = new AiTaskRetryPolicy();
assertFalse(policy.canRetry("DeepSeek request timeout", 3, 3));
}
@Test
public void nextRetryTimeUsesBackoffSequence()
{
AiTaskRetryPolicy policy = new AiTaskRetryPolicy();
Date now = new Date(1_000L);
assertEquals(new Date(31_000L), policy.nextRetryTime(1, now));
assertEquals(new Date(121_000L), policy.nextRetryTime(2, now));
assertEquals(new Date(301_000L), policy.nextRetryTime(3, now));
assertEquals(new Date(301_000L), policy.nextRetryTime(9, now));
}
}

View File

@@ -1261,6 +1261,9 @@ insert into sys_project_structure (node_id, parent_id, node_name, node_type, mod
-- =================================================
drop table if exists front_project_file;
drop table if exists front_ai_usage_ledger;
drop table if exists front_ai_generation_task;
drop table if exists front_ai_quota_bucket;
drop table if exists front_project_generation;
drop table if exists front_project_column;
drop table if exists front_project_table;
@@ -1383,6 +1386,82 @@ create table front_project_generation (
key idx_front_project_generation_project_id (project_id)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='前台项目生成记录';
create table front_ai_generation_task (
task_id bigint(20) not null auto_increment comment 'AI task ID',
generation_id bigint(20) default null comment 'Generation record ID',
project_id bigint(20) not null comment 'Project ID',
user_id bigint(20) not null comment 'Front user ID',
generate_type varchar(50) not null comment 'Generation type',
provider varchar(50) default 'deepseek' comment 'AI provider',
model varchar(100) default 'deepseek-chat' comment 'AI model',
status varchar(30) not null default 'QUEUED' comment 'Task status',
request_hash varchar(64) not null comment 'Request idempotency hash',
request_payload longtext comment 'Request JSON',
result_payload longtext comment 'Result JSON',
error_code varchar(100) default '' comment 'Error code',
error_message varchar(1000) default '' comment 'Error message',
attempts int default 0 comment 'Attempt count',
max_attempts int default 3 comment 'Max attempts',
next_retry_time datetime default null comment 'Next retry time',
locked_by varchar(100) default '' comment 'Worker lock owner',
locked_until datetime default null comment 'Worker lock expiry',
progress int default 0 comment 'Progress percent',
current_step varchar(100) default '' comment 'Current step',
input_tokens int default 0 comment 'Input tokens',
output_tokens int default 0 comment 'Output tokens',
total_tokens int default 0 comment 'Total tokens',
cost_cents int default 0 comment 'Estimated cost in cents',
started_at datetime default null comment 'Started time',
finished_at datetime default null comment 'Finished time',
create_time datetime default null comment 'Create time',
update_time datetime default null comment 'Update time',
primary key (task_id),
key idx_front_ai_task_project (user_id, project_id),
key idx_front_ai_task_status (status, next_retry_time),
key idx_front_ai_task_generation (generation_id),
key idx_front_ai_task_hash (user_id, project_id, request_hash)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI generation task';
create table front_ai_quota_bucket (
bucket_id bigint(20) not null auto_increment comment 'Quota bucket ID',
user_id bigint(20) not null comment 'Front user ID',
period_type varchar(20) not null comment 'DAY or MONTH',
period_key varchar(20) not null comment 'Period key',
task_limit int default 0 comment 'Task limit',
task_used int default 0 comment 'Used tasks',
token_limit int default 0 comment 'Token limit',
token_used int default 0 comment 'Used tokens',
cost_limit_cents int default 0 comment 'Cost limit in cents',
cost_used_cents int default 0 comment 'Used cost in cents',
running_limit int default 1 comment 'Running task limit',
running_count int default 0 comment 'Running task count',
create_time datetime default null comment 'Create time',
update_time datetime default null comment 'Update time',
primary key (bucket_id),
unique key uk_front_ai_quota_bucket (user_id, period_type, period_key)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI quota bucket';
create table front_ai_usage_ledger (
ledger_id bigint(20) not null auto_increment comment 'Usage ledger ID',
task_id bigint(20) default null comment 'Task ID',
generation_id bigint(20) default null comment 'Generation record ID',
project_id bigint(20) not null comment 'Project ID',
user_id bigint(20) not null comment 'Front user ID',
generate_type varchar(50) not null comment 'Generation type',
provider varchar(50) default 'deepseek' comment 'AI provider',
model varchar(100) default 'deepseek-chat' comment 'AI model',
input_tokens int default 0 comment 'Input tokens',
output_tokens int default 0 comment 'Output tokens',
total_tokens int default 0 comment 'Total tokens',
cost_cents int default 0 comment 'Estimated cost in cents',
success char(1) default '0' comment 'Success flag',
error_message varchar(1000) default '' comment 'Error message',
create_time datetime default null comment 'Create time',
primary key (ledger_id),
key idx_front_ai_usage_user (user_id, create_time),
key idx_front_ai_usage_task (task_id)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI usage ledger';
create table front_project_file (
file_id bigint(20) not null auto_increment comment '文件缓存ID',
project_id bigint(20) not null comment '项目ID',

View File

@@ -0,0 +1,77 @@
-- EasyCode front AI task center incremental upgrade
create table if not exists front_ai_generation_task (
task_id bigint(20) not null auto_increment comment 'AI task ID',
generation_id bigint(20) default null comment 'Generation record ID',
project_id bigint(20) not null comment 'Project ID',
user_id bigint(20) not null comment 'Front user ID',
generate_type varchar(50) not null comment 'Generation type',
provider varchar(50) default 'deepseek' comment 'AI provider',
model varchar(100) default 'deepseek-chat' comment 'AI model',
status varchar(30) not null default 'QUEUED' comment 'Task status',
request_hash varchar(64) not null comment 'Request idempotency hash',
request_payload longtext comment 'Request JSON',
result_payload longtext comment 'Result JSON',
error_code varchar(100) default '' comment 'Error code',
error_message varchar(1000) default '' comment 'Error message',
attempts int default 0 comment 'Attempt count',
max_attempts int default 3 comment 'Max attempts',
next_retry_time datetime default null comment 'Next retry time',
locked_by varchar(100) default '' comment 'Worker lock owner',
locked_until datetime default null comment 'Worker lock expiry',
progress int default 0 comment 'Progress percent',
current_step varchar(100) default '' comment 'Current step',
input_tokens int default 0 comment 'Input tokens',
output_tokens int default 0 comment 'Output tokens',
total_tokens int default 0 comment 'Total tokens',
cost_cents int default 0 comment 'Estimated cost in cents',
started_at datetime default null comment 'Started time',
finished_at datetime default null comment 'Finished time',
create_time datetime default null comment 'Create time',
update_time datetime default null comment 'Update time',
primary key (task_id),
key idx_front_ai_task_project (user_id, project_id),
key idx_front_ai_task_status (status, next_retry_time),
key idx_front_ai_task_generation (generation_id),
key idx_front_ai_task_hash (user_id, project_id, request_hash)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI generation task';
create table if not exists front_ai_quota_bucket (
bucket_id bigint(20) not null auto_increment comment 'Quota bucket ID',
user_id bigint(20) not null comment 'Front user ID',
period_type varchar(20) not null comment 'DAY or MONTH',
period_key varchar(20) not null comment 'Period key',
task_limit int default 0 comment 'Task limit',
task_used int default 0 comment 'Used tasks',
token_limit int default 0 comment 'Token limit',
token_used int default 0 comment 'Used tokens',
cost_limit_cents int default 0 comment 'Cost limit in cents',
cost_used_cents int default 0 comment 'Used cost in cents',
running_limit int default 1 comment 'Running task limit',
running_count int default 0 comment 'Running task count',
create_time datetime default null comment 'Create time',
update_time datetime default null comment 'Update time',
primary key (bucket_id),
unique key uk_front_ai_quota_bucket (user_id, period_type, period_key)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI quota bucket';
create table if not exists front_ai_usage_ledger (
ledger_id bigint(20) not null auto_increment comment 'Usage ledger ID',
task_id bigint(20) default null comment 'Task ID',
generation_id bigint(20) default null comment 'Generation record ID',
project_id bigint(20) not null comment 'Project ID',
user_id bigint(20) not null comment 'Front user ID',
generate_type varchar(50) not null comment 'Generation type',
provider varchar(50) default 'deepseek' comment 'AI provider',
model varchar(100) default 'deepseek-chat' comment 'AI model',
input_tokens int default 0 comment 'Input tokens',
output_tokens int default 0 comment 'Output tokens',
total_tokens int default 0 comment 'Total tokens',
cost_cents int default 0 comment 'Estimated cost in cents',
success char(1) default '0' comment 'Success flag',
error_message varchar(1000) default '' comment 'Error message',
create_time datetime default null comment 'Create time',
primary key (ledger_id),
key idx_front_ai_usage_user (user_id, create_time),
key idx_front_ai_usage_task (task_id)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI usage ledger';

View File

@@ -4,6 +4,9 @@
-- =================================================
drop table if exists front_project_file;
drop table if exists front_ai_usage_ledger;
drop table if exists front_ai_generation_task;
drop table if exists front_ai_quota_bucket;
drop table if exists front_project_generation;
drop table if exists front_project_column;
drop table if exists front_project_table;
@@ -144,6 +147,82 @@ create table front_project_generation (
key idx_front_project_generation_project_id (project_id)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='前台项目生成记录';
create table front_ai_generation_task (
task_id bigint(20) not null auto_increment comment 'AI task ID',
generation_id bigint(20) default null comment 'Generation record ID',
project_id bigint(20) not null comment 'Project ID',
user_id bigint(20) not null comment 'Front user ID',
generate_type varchar(50) not null comment 'Generation type',
provider varchar(50) default 'deepseek' comment 'AI provider',
model varchar(100) default 'deepseek-chat' comment 'AI model',
status varchar(30) not null default 'QUEUED' comment 'Task status',
request_hash varchar(64) not null comment 'Request idempotency hash',
request_payload longtext comment 'Request JSON',
result_payload longtext comment 'Result JSON',
error_code varchar(100) default '' comment 'Error code',
error_message varchar(1000) default '' comment 'Error message',
attempts int default 0 comment 'Attempt count',
max_attempts int default 3 comment 'Max attempts',
next_retry_time datetime default null comment 'Next retry time',
locked_by varchar(100) default '' comment 'Worker lock owner',
locked_until datetime default null comment 'Worker lock expiry',
progress int default 0 comment 'Progress percent',
current_step varchar(100) default '' comment 'Current step',
input_tokens int default 0 comment 'Input tokens',
output_tokens int default 0 comment 'Output tokens',
total_tokens int default 0 comment 'Total tokens',
cost_cents int default 0 comment 'Estimated cost in cents',
started_at datetime default null comment 'Started time',
finished_at datetime default null comment 'Finished time',
create_time datetime default null comment 'Create time',
update_time datetime default null comment 'Update time',
primary key (task_id),
key idx_front_ai_task_project (user_id, project_id),
key idx_front_ai_task_status (status, next_retry_time),
key idx_front_ai_task_generation (generation_id),
key idx_front_ai_task_hash (user_id, project_id, request_hash)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI generation task';
create table front_ai_quota_bucket (
bucket_id bigint(20) not null auto_increment comment 'Quota bucket ID',
user_id bigint(20) not null comment 'Front user ID',
period_type varchar(20) not null comment 'DAY or MONTH',
period_key varchar(20) not null comment 'Period key',
task_limit int default 0 comment 'Task limit',
task_used int default 0 comment 'Used tasks',
token_limit int default 0 comment 'Token limit',
token_used int default 0 comment 'Used tokens',
cost_limit_cents int default 0 comment 'Cost limit in cents',
cost_used_cents int default 0 comment 'Used cost in cents',
running_limit int default 1 comment 'Running task limit',
running_count int default 0 comment 'Running task count',
create_time datetime default null comment 'Create time',
update_time datetime default null comment 'Update time',
primary key (bucket_id),
unique key uk_front_ai_quota_bucket (user_id, period_type, period_key)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI quota bucket';
create table front_ai_usage_ledger (
ledger_id bigint(20) not null auto_increment comment 'Usage ledger ID',
task_id bigint(20) default null comment 'Task ID',
generation_id bigint(20) default null comment 'Generation record ID',
project_id bigint(20) not null comment 'Project ID',
user_id bigint(20) not null comment 'Front user ID',
generate_type varchar(50) not null comment 'Generation type',
provider varchar(50) default 'deepseek' comment 'AI provider',
model varchar(100) default 'deepseek-chat' comment 'AI model',
input_tokens int default 0 comment 'Input tokens',
output_tokens int default 0 comment 'Output tokens',
total_tokens int default 0 comment 'Total tokens',
cost_cents int default 0 comment 'Estimated cost in cents',
success char(1) default '0' comment 'Success flag',
error_message varchar(1000) default '' comment 'Error message',
create_time datetime default null comment 'Create time',
primary key (ledger_id),
key idx_front_ai_usage_user (user_id, create_time),
key idx_front_ai_usage_task (task_id)
) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Front AI usage ledger';
create table front_project_file (
file_id bigint(20) not null auto_increment comment '文件缓存ID',
project_id bigint(20) not null comment '项目ID',