This commit is contained in:
2026-02-24 22:11:30 +08:00
parent 7490298ded
commit 903dc7ce93
5 changed files with 4 additions and 1020 deletions

View File

@@ -104,7 +104,6 @@ function buildRoutesFromNav(config) {
const hiddenRoutes = [
{ path: 'trends/heat', name: '热度分析', component: () => import('../views/trends/Heat.vue'), meta: { hidden: true } },
{ path: 'material/mix-task', name: '混剪任务', component: () => import('../views/material/MixTaskList.vue'), meta: { hidden: true } },
{ path: 'material/group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue'), meta: { hidden: true } },
{ path: 'user/profile', name: '个人中心', component: () => import('../views/user/Profile.vue'), meta: { hidden: true } }
]

View File

@@ -192,7 +192,7 @@
</div>
<div class="meta-row">
<span class="meta-label">当前余额</span>
<span class="meta-value">2,450 积分</span>
<span class="meta-value">{{ userStore.remainingPoints.toLocaleString() }} 积分</span>
</div>
</div>
</div>
@@ -209,6 +209,7 @@
import { ref } from 'vue'
import { CloudUploadOutlined, CrownFilled } from '@ant-design/icons-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { useUserStore } from '@/stores/user'
import VideoSelector from '@/components/VideoSelector.vue'
import VoiceSelector from '@/components/VoiceSelector.vue'
import ResultPanel from '@/components/ResultPanel.vue'
@@ -219,6 +220,7 @@ import PipelineProgress from '@/components/PipelineProgress.vue'
import { useIdentifyFaceController } from './hooks/useIdentifyFaceController'
const voiceStore = useVoiceCopyStore()
const userStore = useUserStore()
const dragOver = ref(false)
// ==================== 初始化 Controller ====================

View File

@@ -1,713 +0,0 @@
<template>
<FullWidthLayout :show-back="true" @back="router.back()" class="mix-task-list-layout">
<!-- 页面标题 -->
<template #header>
<div class="page-header">
<div class="page-header__icon">
<VideoCameraOutlined />
</div>
<div class="page-header__content">
<h1 class="page-header__title">混剪任务</h1>
<p class="page-header__subtitle">管理和查看混剪任务的进度和结果</p>
</div>
<div class="page-header__actions">
<a-button @click="handleRefresh">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</div>
</div>
</template>
<!-- 页面内容 -->
<div class="mix-task-list">
<!-- 筛选条件 -->
<div class="mix-task-list__filters">
<a-space>
<a-select
v-model:value="filters.status"
style="width: 120px"
placeholder="任务状态"
@change="handleFilterChange"
allow-clear
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="running">处理中</a-select-option>
<a-select-option value="success">已完成</a-select-option>
<a-select-option value="failed">失败</a-select-option>
</a-select>
<a-input
v-model="filters.title"
placeholder="搜索标题"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.createTime"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 任务列表 -->
<div class="mix-task-list__content">
<a-spin :spinning="loading" tip="加载中...">
<a-table
:data-source="taskList"
:columns="columns"
:row-key="record => record.id"
:pagination="paginationConfig"
@change="handleTableChange"
:expanded-row-keys="expandedRowKeys"
@expandedRowsChange="handleExpandedRowsChange"
:scroll="{ x: 1000 }"
>
<!-- 标题列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="title-cell">
<strong>{{ record.title }}</strong>
<a-tag v-if="record.text" size="small" style="margin-left: var(--space-2)">有文案</a-tag>
</div>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<div style="min-width: 100px">
<div style="font-size: 12px; margin-bottom: 4px">{{ record.progress }}%</div>
<a-progress
:percent="record.progress"
:status="getProgressStatus(record.status)"
size="small"
:show-info="false"
/>
</div>
</template>
<!-- 创建时间列 -->
<template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
<!-- 完成时间列 -->
<template v-else-if="column.key === 'finishTime'">
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
</template>
<!-- 生成结果列 -->
<template v-else-if="column.key === 'outputUrls'">
<div v-if="record.outputUrls && record.outputUrls.length > 0">
<a-tag color="success">{{ record.outputUrls.length }} 个视频</a-tag>
</div>
<span v-else>-</span>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="record.status === 'success' && record.outputUrls && record.outputUrls.length > 0"
type="primary"
size="small"
@click="handleDownloadAll(record.id)"
>
<template #icon>
<DownloadOutlined />
</template>
<span>下载</span>
</a-button>
<a-button
v-if="record.status === 'running'"
size="small"
@click="handleCancel(record.id)"
>
取消
</a-button>
<a-popconfirm
title="确定删除这个任务吗删除后无法恢复"
@confirm="() => handleDelete(record.id)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<!-- 展开行内容 -->
<template #expandedRowRender="{ record }">
<div class="expanded-content">
<!-- 任务详情 -->
<div v-if="record.text" class="task-text">
<strong>文案内容:</strong>
<p>{{ record.text }}</p>
</div>
<!-- 生成结果 -->
<div v-if="record.outputUrls && record.outputUrls.length > 0" class="task-results">
<strong>生成结果:</strong>
<div class="result-list">
<div
v-for="(url, index) in record.outputUrls"
:key="index"
class="result-item"
>
<a-button
v-if="record.status === 'success'"
type="link"
@click="handlePreviewSignedUrl(record.id, index)"
>
<PlayCircleOutlined />
视频 {{ index + 1 }}
</a-button>
<a-button
v-if="record.status === 'success'"
type="link"
size="small"
@click="handleDownloadSignedUrl(record.id, index)"
>
<DownloadOutlined />
</a-button>
<span v-else class="processing-tip">
视频 {{ index + 1 }} (处理中...)
</span>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="record.errorMsg" class="task-error">
<a-alert
type="error"
:message="record.errorMsg"
show-icon
/>
</div>
</div>
</template>
</a-table>
</a-spin>
</div>
</div>
</FullWidthLayout>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
ReloadOutlined,
SearchOutlined,
PlayCircleOutlined,
DownloadOutlined,
VideoCameraOutlined
} from '@ant-design/icons-vue'
import { MixTaskService } from '@/api/mixTask'
import { formatDate } from '@/utils/file'
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
const router = useRouter()
// 数据
const loading = ref(false)
const taskList = ref([])
const expandedRowKeys = ref([])
const refreshInterval = ref(null) // 定时刷新定时器
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
fixed: 'left'
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 250,
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 150
},
{
title: '生成结果',
dataIndex: 'outputUrls',
key: 'outputUrls',
width: 120
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
},
{
title: '完成时间',
dataIndex: 'finishTime',
key: 'finishTime',
width: 180
},
{
title: '操作',
key: 'actions',
width: 300,
fixed: 'right'
}
]
// 筛选条件
const filters = reactive({
status: '',
title: '',
createTime: undefined
})
// 分页配置
const paginationConfig = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page, pageSize) => {
paginationConfig.current = page
paginationConfig.pageSize = pageSize
handlePageChange(page, pageSize)
},
onShowSizeChange: (current, size) => {
paginationConfig.current = 1
paginationConfig.pageSize = size
handlePageChange(1, size)
}
})
// 构建查询参数
const buildQueryParams = () => {
const params = {
pageNo: paginationConfig.current,
pageSize: paginationConfig.pageSize,
status: filters.status || undefined,
title: filters.title || undefined
}
// 处理日期范围
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
params.createTimeStart = `${filters.createTime[0]} 00:00:00`
params.createTimeEnd = `${filters.createTime[1]} 23:59:59`
}
return params
}
// 加载任务列表
const loadTaskList = async () => {
loading.value = true
try {
const res = await MixTaskService.getTaskPage(buildQueryParams())
if (res.code === 0) {
taskList.value = res.data.list || []
paginationConfig.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载任务列表失败:', error)
message.error('加载失败,请重试')
} finally {
loading.value = false
}
}
// 筛选
const handleFilterChange = () => {
paginationConfig.current = 1
loadTaskList()
}
const handleResetFilters = () => {
filters.status = ''
filters.title = ''
filters.createTime = undefined
paginationConfig.current = 1
loadTaskList()
}
// 分页
const handlePageChange = (page, pageSize) => {
loadTaskList()
}
// 表格变化
const handleTableChange = (pag, filters, sorter) => {
console.log('表格变化:', pag, filters, sorter)
}
// 展开行变化
const handleExpandedRowsChange = (expandedRows) => {
expandedRowKeys.value = expandedRows
}
// 刷新
const handleRefresh = () => {
loadTaskList()
}
// 重新生成
const handleRetry = (id) => {
Modal.confirm({
title: '确认重新生成',
content: '确定要重新生成这个任务吗?',
onOk: async () => {
try {
await MixTaskService.retryTask(id)
message.success('已重新提交任务')
loadTaskList()
} catch (error) {
message.error('操作失败')
}
}
})
}
// 取消任务
const handleCancel = (id) => {
Modal.confirm({
title: '确认取消',
content: '确定要取消这个任务吗?',
onOk: async () => {
try {
await MixTaskService.cancelTask(id)
message.success('已取消任务')
loadTaskList()
} catch (error) {
message.error('操作失败')
}
}
})
}
// 删除任务
const handleDelete = (id) => {
MixTaskService.deleteTask(id)
.then(() => {
message.success('删除成功')
loadTaskList()
})
.catch(() => {
message.error('删除失败')
})
}
// 下载单个视频
const handleDownload = (url) => {
const link = document.createElement('a')
link.href = url
link.download = 'video'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 下载单个视频使用签名URL
const handlePreviewSignedUrl = async (taskId, index) => {
try {
message.loading('正在获取预览链接...', 0)
const res = await MixTaskService.getSignedUrls(taskId)
message.destroy()
if (res.code === 0 && res.data && res.data[index]) {
window.open(res.data[index], '_blank')
} else {
message.warning('获取预览链接失败')
}
} catch (error) {
console.error('获取预览链接失败:', error)
message.destroy()
message.error('获取预览链接失败')
}
}
const handleDownloadSignedUrl = async (taskId, index) => {
try {
message.loading('正在获取下载链接...', 0)
const res = await MixTaskService.getSignedUrls(taskId)
message.destroy()
if (res.code === 0 && res.data && res.data[index]) {
handleDownload(res.data[index])
} else {
message.warning('获取下载链接失败')
}
} catch (error) {
console.error('获取下载链接失败:', error)
message.destroy()
message.error('获取下载链接失败')
}
}
// 批量下载所有视频
const handleDownloadAll = async (taskId) => {
try {
message.loading('正在获取下载链接...', 0)
const res = await MixTaskService.getSignedUrls(taskId)
message.destroy()
if (res.code === 0 && res.data && res.data.length > 0) {
const urls = res.data
message.loading('正在准备下载...', 0)
// 逐个触发下载,避免浏览器阻止多个弹窗
urls.forEach((url, index) => {
setTimeout(() => {
console.log('下载视频:', url)
handleDownload(url)
}, index * 500) // 每个下载间隔500ms
})
message.destroy()
message.success(`已触发 ${urls.length} 个视频的下载`)
} else {
message.warning('没有可下载的视频')
}
} catch (error) {
console.error('获取下载链接失败:', error)
message.destroy()
message.error('获取下载链接失败')
}
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
pending: '待处理',
running: '处理中',
success: '已完成',
failed: '失败'
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error'
}
return colorMap[status] || 'default'
}
// 获取进度条状态
const getProgressStatus = (status) => {
const statusMap = {
pending: 'normal',
running: 'active',
success: 'success',
failed: 'exception'
}
return statusMap[status] || 'normal'
}
// 初始化
onMounted(() => {
loadTaskList()
// 开启定时刷新每30秒检查一次running状态的任务
startAutoRefresh()
})
// 组件卸载时清理定时器
onUnmounted(() => {
stopAutoRefresh()
})
// 开启自动刷新
const startAutoRefresh = () => {
// 清除可能存在的旧定时器
stopAutoRefresh()
refreshInterval.value = setInterval(() => {
// 只在页面可见时刷新,避免后台浪费资源
if (document.visibilityState === 'visible') {
loadTaskList()
}
}, 30000) // 30秒刷新一次
}
// 停止自动刷新
const stopAutoRefresh = () => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
refreshInterval.value = null
}
}
</script>
<style scoped lang="less">
// 页面头部样式
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: var(--space-3);
&__icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
box-shadow: var(--shadow-inset-card);
}
&__content {
flex: 1;
}
&__title {
font-size: 24px;
font-weight: 600;
color: var(--color-text);
margin: 0;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: var(--color-text-secondary);
margin: 4px 0 0;
}
&__actions {
display: flex;
gap: 12px;
}
}
.mix-task-list {
min-height: calc(100vh - 140px);
background: var(--color-bg);
padding: var(--space-3) 0;
}
.mix-task-list__filters {
margin-bottom: var(--space-3);
padding: var(--space-3);
background: var(--color-surface);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.mix-task-list__content {
flex: 1;
overflow: auto;
background: var(--color-surface);
border-radius: 8px;
border: 1px solid var(--color-border);
padding: var(--space-3);
}
.title-cell {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.expanded-content {
padding: var(--space-3);
background: var(--color-bg);
border-radius: 8px;
margin: var(--space-2);
}
.task-text {
margin-bottom: var(--space-3);
p {
margin: var(--space-2) 0 0 0;
padding: var(--space-2);
background: var(--color-surface);
border-radius: 8px;
line-height: 1.6;
}
}
.task-results {
margin-bottom: var(--space-3);
}
.result-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.result-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 6px 12px;
background: var(--color-surface);
border-radius: 8px;
font-size: 13px;
}
.processing-tip {
color: var(--color-text-secondary);
font-size: 12px;
}
.task-error {
margin-bottom: var(--space-2);
}
:deep(.ant-btn .anticon) {
vertical-align: middle;
}
</style>

View File

@@ -141,6 +141,7 @@
<!-- 取消按钮 -->
<a-button
v-if="isStatus(record.status, 'running')"
type="link"
size="small"
@click="handleCancel(record.id)"
>

View File

@@ -1,305 +0,0 @@
# AI 服务积分扣减公共服务设计文档
> 版本: v1.2
> 日期: 2025-02-20
> 状态: 待确认
> 用途: 业务规范文档,供后续开发参考
---
# 模块一:公共积分扣减服务
## 1.1 服务定位
**积分扣减公共服务** - 位于 `tik` 模块下,供所有 AI 服务复用的基础设施。
**职责:**
- 积分配置查询
- 积分预检与扣减
- 预扣记录管理
- 积分流水记录
## 1.2 核心能力
| 能力 | 说明 | 使用场景 |
|------|------|---------|
| 获取积分配置 | 根据平台+类型获取消耗积分 | 所有业务调用前 |
| 预检积分 | 检查余额是否充足,不足抛异常 | 调用前验证 |
| 即时扣减 | 直接扣减并记录流水 | 同步调用成功后 |
| 创建预扣 | 创建待确认的扣减记录 | 流式/异步任务开始时 |
| 确认扣减 | 确认预扣,实际扣减积分 | 流式结束/任务成功时 |
| 取消预扣 | 删除预扣记录,不扣费 | 流式出错/任务失败时 |
## 1.3 数据模型
### 用户积分muye_member_user_profile
- `remaining_points` - 剩余积分(扣减来源)
- `used_points` - 已用积分
### 积分记录muye_point_record
- `point_amount` - 变动数量(负数为扣减)
- `biz_type` - 业务类型dify_chat/voice_tts 等)
- `biz_id` - 业务关联ID
- `status` - 状态:`pending`(预扣) / `confirmed`(已确认) / `canceled`(已取消)
### 积分配置muye_ai_model_config
- `platform` - 平台dify / tikhub / voice / digital_human
- `model_type` - 类型high/low / tts/clone / latentsync/kling
- `consume_points` - 消耗积分
- `api_key` - API 密钥Dify 工作流密钥等)★
- `api_url` - API 地址(可选,覆盖默认配置)
## 1.4 业务规则
### 积分扣减规则
- **原子性**:使用 SQL 条件更新,确保不会超扣
- **乐观锁**`WHERE remaining_points >= points`
- **幂等性**:同一预扣记录只能确认/取消一次
### 预扣过期规则
- 预扣记录超过 30 分钟自动清理
- 定时任务每 5 分钟执行一次清理
### 异常处理
- 积分不足:抛出 `POINTS_INSUFFICIENT` 异常
- 配置不存在:抛出 `POINTS_CONFIG_NOT_FOUND` 异常
- 扣减失败:抛出 `POINTS_DEDUCT_FAILED` 异常
## 1.5 依赖关系
```
业务服务Dify/TikHub/Voice/DigitalHuman
PointsService公共服务
├── AiModelConfigService → 获取积分配置
├── MemberUserProfileMapper → 扣减积分
└── PointRecordMapper → 记录流水
```
---
# 模块二Dify 工作流集成
## 2.1 业务概述
**Dify 工作流** - AI 对话服务,支持智能体配置和流式响应。
**设计原则:**
- 所有智能体共用一个"文案生成"工作流(同一个 api_key
- 智能体之间只通过 `systemPrompt` 区分
- 前端只需传 `agentId`
**核心流程:**
1. 前端传入 `agentId` + `content`
2. 后端通过 `agentId` 获取智能体的 `systemPrompt`
3. 调用 Dify API固定工作流传入 `sysPrompt` 参数
4. 流式返回内容
5. 流结束或用户停止时扣费
## 2.2 扣费流程
```
┌─────────────────────────────────────────────────────────────┐
│ Dify 流式扣费流程 │
├─────────────────────────────────────────────────────────────┤
│ 1. 获取智能体配置systemPrompt
│ 2. 获取积分配置platform=dify
│ 3. 预检积分 → 积分不足则拒绝 │
│ 4. 创建预扣记录 │
│ 5. 调用 Dify 流式 API传入 sysPrompt
│ ├─ 流正常结束 → 确认扣费(全额) │
│ ├─ 用户取消 → 确认扣费(按实际消耗或最低消费) │
│ └─ 出错 → 取消预扣(不扣费) │
│ 6. 记录使用记录 │
└─────────────────────────────────────────────────────────────┘
```
## 2.3 接口定义
### 阻塞模式
- **URL**: `POST /api/tik/dify/chat`
- **入参**: agentId, content, conversationId
- **出参**: content, conversationId, consumePoints
### 流式模式
- **URL**: `POST /api/tik/dify/chat/stream`
- **入参**: agentId, content, conversationId
- **出参**: SSE 流event: message / done
**说明:**
- `agentId` 用于获取智能体的 `systemPrompt`
- 所有智能体共用同一个 Dify 工作流
## 2.4 配置方案
**Dify 配置存储在 `muye_ai_model_config` 表:**
| 字段 | 值 | 说明 |
|------|-----|------|
| model_name | Dify 文案生成 | 配置名称 |
| platform | dify | 平台标识 |
| model_type | writing | 类型 |
| consume_points | 10 | 消耗积分 |
| api_key | app-xxx | Dify 工作流密钥 ★ |
| status | 1 | 启用 |
**配置文件只存公共配置:**
```yaml
yudao:
dify:
api-url: http://8.155.172.147:8088
timeout: 60
```
## 2.5 智能体表(无需修改)
现有 `muye_ai_agent` 表已有字段:
| 字段 | 说明 |
|------|------|
| agent_id | 智能体ID |
| agent_name | 智能体名称 |
| system_prompt | 系统提示词(传递给 Dify 的 sysPrompt 参数) |
| status | 状态 |
**调用流程:**
1. 通过 `agentId` 获取智能体的 `systemPrompt`
2.`muye_ai_model_config` 获取 `api_key` + `consume_points`platform=dify
3. 调用 Dify API传入 `sysPrompt` 参数
---
# 模块三:各业务积分扣减集成
## 3.1 扣费模式总览
| 业务 | 扣费模式 | 扣费时机 | 失败处理 |
|------|---------|---------|---------|
| Dify 工作流 | 流式结束扣费 | 流结束/用户停止时 | 不扣费 |
| TikHub | 成功后扣费 | API 调用成功后 | 不扣费 |
| 语音合成 | 成功后扣费 | TTS 生成成功后 | 不扣费 |
| 数字人合成 | 任务完成扣费 | 任务成功完成时 | 不扣费 |
**统一原则:失败不扣费**
## 3.2 TikHub 集成
### 业务场景
- 抖音/小红书数据抓取
- 用户信息、视频、帖子等
### 扣费流程
```
1. 获取积分配置platform=tikhub, modelType=fetch
2. 预检积分
3. 调用 TikHub API
4. 成功 → 扣减积分
5. 失败 → 不扣费
```
### 积分配置
| platform | model_type | consume_points |
|----------|------------|----------------|
| tikhub | fetch | 5 |
## 3.3 语音合成集成
### 业务场景
- TTS 语音生成
- 音色克隆
### 扣费流程
```
1. 获取积分配置platform=voice, modelType=tts/clone
2. 预检积分
3. 执行语音合成
4. 成功 → 扣减积分
5. 失败 → 不扣费
```
### 积分配置
| platform | model_type | consume_points |
|----------|------------|----------------|
| voice | tts | 2 |
| voice | clone | 10 |
## 3.4 数字人合成集成
### 业务场景
- 口型同步视频生成
- 支持 Latentsync / 可灵 两种供应商
### 扣费流程
```
1. 获取积分配置platform=digital_human, modelType=latentsync/kling
2. 预检积分
3. 创建预扣记录
4. 创建任务并异步处理
5. 任务成功 → 确认扣费
6. 任务失败/取消 → 取消预扣
```
### 积分配置
| platform | model_type | consume_points |
|----------|------------|----------------|
| digital_human | latentsync | 15 |
| digital_human | kling | 20 |
---
# 附录
## A. 业务类型枚举
| 类型码 | 说明 |
|--------|------|
| dify_chat | Dify对话 |
| ai_chat | AI聊天 |
| image_gen | 图像生成 |
| tikhub_fetch | TikHub数据抓取 |
| voice_tts | 语音合成 |
| voice_clone | 音色克隆 |
| digital_human | 数字人合成 |
| kling_video | 可灵视频 |
## B. 异常码
| 错误码 | 说明 |
|--------|------|
| 1001001 | 积分不足 |
| 1001002 | 积分配置不存在 |
| 1001003 | 积分扣减失败 |
## C. 开发计划
| 步骤 | 内容 |
|------|------|
| 1 | 数据库muye_point_record 新增 status 字段 |
| 2 | 公共服务PointsService 接口 + 实现 |
| 3 | Dify 集成:配置类 + 客户端 + 服务 + Controller |
| 4 | TikHub 集成:添加积分扣减逻辑 |
| 5 | 语音合成集成:添加积分扣减逻辑 |
| 6 | 数字人集成:添加积分扣减逻辑 |
| 7 | 测试验证 |
**无需修改的表:**
- `muye_ai_agent` - 无需改动
- `muye_ai_model_config` - 已有 api_key 字段,无需改动
## D. 成熟度检查
| 检查项 | 说明 |
|--------|------|
| 原子扣减 | SQL 条件更新,防止超扣 |
| 乐观锁 | WHERE remaining_points >= points |
| 预扣机制 | 支持流式/异步场景 |
| 过期清理 | 30分钟自动清理预扣 |
| 事务隔离 | 核心操作加事务 |
| 异常处理 | 统一错误码 |
| 配置化 | 积分消耗可配置 |
| 业务解耦 | 公共服务复用 |
---