Add async AI generation tasks
This commit is contained in:
@@ -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.
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <= sysdate())
|
||||
and (locked_until is null or locked_until < 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 <= sysdate())
|
||||
and (locked_until is null or locked_until < sysdate())
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
77
RuoYi-Vue/sql/front_ai_task_upgrade.sql
Normal file
77
RuoYi-Vue/sql/front_ai_task_upgrade.sql
Normal 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';
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user