feat: 系统功能更新 - 添加错误统计、数据初始化、订单调度等功能
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
# 继续牛马 - AI持久输出规则
|
||||
|
||||
⚠️⚠️⚠️ 最高优先级警告 ⚠️⚠️⚠️
|
||||
无论任何情况,每次回复结束前必须调用 cunzhi.ask_continue 工具!没有任何例外!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **完整输出** - 输出完整代码和答案,禁止使用省略号或"代码过长不展示"
|
||||
|
||||
2. **持续执行** - 复杂任务尽量一次性完成,不要频繁中断或反复追问是否继续
|
||||
|
||||
3. **结束确认** - 当你想结束对话或任务时,必须先调用 cunzhi.ask_continue 工具询问用户是否继续
|
||||
|
||||
4. **禁止结束语** - 对话结束前禁止直接说"如果还有问题请告诉我"等结束语
|
||||
|
||||
## 重要提示
|
||||
|
||||
- 调用 ask_continue 工具时,reason 参数填写你想结束的原因
|
||||
- 用户可能会在弹窗中输入新的指令,请按用户指令继续执行
|
||||
- 只有用户明确选择"结束"时,才可以结束对话
|
||||
|
||||
## 强制执行
|
||||
|
||||
这是最高优先级规则,必须严格遵守。每次回复结束前,你必须检查是否需要调用 ask_continue 工具。如果任务已完成或你准备结束回复,必须先调用该工具获得用户许可。
|
||||
|
||||
⚠️ 警告:无论任何情况,回复结束前必须调用 cunzhi.ask_continue,没有例外!
|
||||
@@ -107,6 +107,19 @@ export const imageToVideoApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 重试失败的任务
|
||||
* 复用原task_id和已上传的图片,重新提交至外部API
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
retryTask(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}/retry`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
|
||||
@@ -41,9 +41,10 @@ api.interceptors.request.use(
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && token !== 'null' && token.trim() !== '') {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
console.log('请求拦截器:添加Authorization头,token长度:', token.length)
|
||||
// 打印token前30字符用于调试
|
||||
console.log('请求拦截器:添加Authorization头,token前30字符:', token.substring(0, 30), '请求URL:', config.url)
|
||||
} else {
|
||||
console.warn('请求拦截器:未找到有效的token')
|
||||
console.warn('请求拦截器:未找到有效的token,请求URL:', config.url)
|
||||
}
|
||||
} else {
|
||||
console.log('请求拦截器:登录相关请求,不添加token:', config.url)
|
||||
@@ -69,34 +70,47 @@ api.interceptors.response.use(
|
||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
// 清除无效的token并跳转到登录页
|
||||
// 清除无效的token并跳转到欢迎页
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// 避免重复跳转
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.error('认证失败,请重新登录')
|
||||
router.push('/login')
|
||||
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
// 返回错误,让调用方知道这是认证失败
|
||||
return Promise.reject(new Error('认证失败:收到HTML响应'))
|
||||
}
|
||||
|
||||
// 检查302重定向
|
||||
if (response.status === 302) {
|
||||
console.error('收到302重定向,可能是认证失败:', response.config.url)
|
||||
// 检查401未授权(Token过期)
|
||||
if (response.status === 401) {
|
||||
console.error('收到401,Token已过期:', response.config.url)
|
||||
|
||||
// 只有非登录请求才清除token并跳转
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.error('认证失败,请重新登录')
|
||||
router.push('/login')
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
return Promise.reject(new Error('认证失败:Token已过期'))
|
||||
}
|
||||
|
||||
// 检查302重定向
|
||||
if (response.status === 302) {
|
||||
console.error('收到302重定向,可能是认证失败:', response.config.url)
|
||||
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
return Promise.reject(new Error('认证失败:302重定向'))
|
||||
}
|
||||
@@ -119,9 +133,9 @@ api.interceptors.response.use(
|
||||
if (!isLoginRequest) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.error('认证失败,请重新登录')
|
||||
router.push('/login')
|
||||
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
@@ -138,9 +152,10 @@ api.interceptors.response.use(
|
||||
// 302也可能是认证失败导致的
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.error('认证失败,请重新登录')
|
||||
router.push('/login')
|
||||
// 跳转到欢迎页
|
||||
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -81,6 +81,18 @@ export const textToVideoApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 重试失败的任务
|
||||
* 复用原task_id,重新提交至外部API
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
retryTask(taskId) {
|
||||
return request({
|
||||
url: `/text-to-video/tasks/${taskId}/retry`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
|
||||
@@ -230,6 +230,8 @@ export default {
|
||||
userAvatar: 'User Avatar',
|
||||
firstFrame: 'First Frame',
|
||||
promptPlaceholder: 'Describe the content you want to generate with the image',
|
||||
tipWarning: '⚠️ Do not upload images of real people or anime IP',
|
||||
tip1: '🎬 Supports camera movements, character actions, scene transitions, etc.',
|
||||
optimizing: 'Optimizing...',
|
||||
optimizePrompt: 'Optimize',
|
||||
hdMode: 'HD Mode',
|
||||
@@ -324,8 +326,10 @@ export default {
|
||||
uploadSuccess: 'Successfully uploaded {count} images',
|
||||
imageRemoved: 'Image removed',
|
||||
promptPlaceholder: 'Example: a coffee advertisement\n\nTip: Simple description is enough, AI will automatically optimize it into professional storyboard\nSupports Chinese or English input, the system will automatically translate and optimize it into professional storyboard description',
|
||||
tip1: '💡 AI will automatically generate professional storyboards based on your description',
|
||||
tip2: '🎬 Supports various scene compositions and camera types',
|
||||
tipWarning: '⚠️ Do not upload images of real people or anime IP',
|
||||
tip1: '🎬 Supports camera movements, character actions, scene transitions, etc.',
|
||||
imageCost: 'Storyboard generation costs 30 points',
|
||||
videoCost: 'Video generation costs 30 points',
|
||||
videoPromptLabel: 'Video Description',
|
||||
videoPromptPlaceholder: 'Describe actions and camera movements in the video, e.g.: camera slowly zooms in, person turns around and smiles',
|
||||
videoTip1: '💡 Describe actions and camera movements for more vivid video generation',
|
||||
@@ -480,14 +484,14 @@ export default {
|
||||
free: 'Free',
|
||||
standard: 'Standard',
|
||||
professional: 'Professional',
|
||||
perMonth: '/month',
|
||||
perMonth: '/year',
|
||||
subscribe: 'Subscribe Now',
|
||||
renew: 'Renew',
|
||||
upgrade: 'Upgrade',
|
||||
features: 'Features',
|
||||
unlimited: 'Unlimited',
|
||||
limited: 'Limited',
|
||||
pointsPerMonth: 'Points/Month',
|
||||
pointsPerMonth: 'Points/Year',
|
||||
videoQuality: 'Video Quality',
|
||||
support: 'Support',
|
||||
priorityQueue: 'Priority Queue',
|
||||
@@ -506,8 +510,8 @@ export default {
|
||||
currentPackage: 'Current Plan',
|
||||
firstPurchaseDiscount: 'First Purchase Discount up to 15% off',
|
||||
bestValue: 'Best Value',
|
||||
standardPoints: '200 points per month',
|
||||
premiumPoints: '1000 points per month',
|
||||
standardPoints: '6000 points/year',
|
||||
premiumPoints: '12000 points/year',
|
||||
freeNewUserBonus: 'New users get 50 points free on first login',
|
||||
fastGeneration: 'Fast Generation',
|
||||
superFastGeneration: 'Super Fast Generation',
|
||||
|
||||
@@ -232,6 +232,8 @@ export default {
|
||||
userAvatar: '用户头像',
|
||||
firstFrame: '首帧',
|
||||
promptPlaceholder: '结合图片,描述想要生成的内容',
|
||||
tipWarning: '⚠️ 图片不能上传真人、涉及动漫IP等类型的图片',
|
||||
tip1: '🎬 支持描述镜头推拉、人物动作、场景变化等',
|
||||
optimizing: '优化中...',
|
||||
optimizePrompt: '一键优化',
|
||||
hdMode: '高清模式',
|
||||
@@ -327,11 +329,13 @@ export default {
|
||||
uploadSuccess: '成功上传 {count} 张图片',
|
||||
imageRemoved: '已删除图片',
|
||||
promptPlaceholder: '例如:一个咖啡的广告\n\n提示:简单描述即可,AI会自动优化成专业的分镜图\n支持中文或英文输入,系统会自动翻译并优化为专业的分镜图描述',
|
||||
tip1: '💡 AI会根据您的描述自动生成专业分镜图',
|
||||
tip2: '🎬 支持多种画面构图和镜头类型描述',
|
||||
tipWarning: '⚠️ 图片不能上传真人、涉及动漫IP等类型的图片',
|
||||
tip1: '🎬 支持描述镜头推拉、人物动作、场景变化等',
|
||||
imageCost: '生成分镜图消耗30积分',
|
||||
videoCost: '生成视频消耗30积分',
|
||||
videoPromptLabel: '视频描述',
|
||||
videoPromptPlaceholder: '描述视频中的动作、镜头运动等,例如:镜头缓慢推进,人物转身微笑',
|
||||
videoTip1: '💡 描述画面中的动作和镜头运动,AI将生成更生动的视频',
|
||||
videoTip1: '⚠️ 图片不能上传真人、涉及动漫IP等类型的图片',
|
||||
videoTip2: '🎬 支持描述镜头推拉、人物动作、场景变化等',
|
||||
storyboardReadyHint: '分镜图已准备就绪,输入视频描述后点击生成视频',
|
||||
optimizing: '优化中...',
|
||||
@@ -494,14 +498,14 @@ export default {
|
||||
free: '免费版',
|
||||
standard: '标准版',
|
||||
professional: '专业版',
|
||||
perMonth: '/月',
|
||||
perMonth: '/年',
|
||||
subscribe: '立即订阅',
|
||||
renew: '续费',
|
||||
upgrade: '升级',
|
||||
features: '功能特性',
|
||||
unlimited: '无限',
|
||||
limited: '有限',
|
||||
pointsPerMonth: '积分/月',
|
||||
pointsPerMonth: '积分/年',
|
||||
videoQuality: '视频质量',
|
||||
support: '客服支持',
|
||||
priorityQueue: '优先队列',
|
||||
@@ -520,8 +524,8 @@ export default {
|
||||
currentPackage: '当前套餐',
|
||||
firstPurchaseDiscount: '首购低至8.5折',
|
||||
bestValue: '超值之选',
|
||||
standardPoints: '每月200积分',
|
||||
premiumPoints: '每月1000积分',
|
||||
standardPoints: '6000积分/年',
|
||||
premiumPoints: '12000积分/年',
|
||||
freeNewUserBonus: '新用户首次登陆免费获得50积分',
|
||||
fastGeneration: '快速通道生成',
|
||||
superFastGeneration: '极速通道生成',
|
||||
@@ -657,6 +661,7 @@ export default {
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款',
|
||||
unpaid: '未支付',
|
||||
allPaymentMethods: '全部支付方式',
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
paypal: 'PayPal',
|
||||
@@ -780,6 +785,12 @@ export default {
|
||||
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等',
|
||||
storyboardSystemPrompt: '分镜图系统引导词',
|
||||
storyboardSystemPromptTip: '此引导词会自动添加到用户提示词前面,用于统一分镜图生成风格',
|
||||
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...'
|
||||
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...',
|
||||
membershipUpdateSuccess: '会员等级配置更新成功',
|
||||
membershipUpdateFailed: '会员等级配置更新失败',
|
||||
loadMembershipFailed: '加载会员配置失败',
|
||||
usingDefaultConfig: '使用默认配置',
|
||||
enterValidNumber: '请输入有效的数字',
|
||||
unknown: '未知错误'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,12 @@ const routes = [
|
||||
component: () => import('@/views/ApiManagement.vue'),
|
||||
meta: { title: 'API管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/error-statistics',
|
||||
name: 'ErrorStatistics',
|
||||
component: () => import('@/views/ErrorStatistics.vue'),
|
||||
meta: { title: '错误统计', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/hello',
|
||||
name: 'HelloWorld',
|
||||
@@ -246,6 +252,13 @@ router.beforeEach(async (to, from, next) => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 检查localStorage中的token是否被清除(例如JWT过期后被request.js清除)
|
||||
// 如果token被清除但store中仍有用户信息,则同步清除store
|
||||
const storedToken = localStorage.getItem('token')
|
||||
if (!storedToken && userStore.isAuthenticated) {
|
||||
userStore.clearUserData()
|
||||
}
|
||||
|
||||
// 优化:只在首次访问时初始化用户状态
|
||||
if (!userStore.initialized) {
|
||||
await userStore.init()
|
||||
|
||||
@@ -128,19 +128,18 @@ export const useUserStore = defineStore('user', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 从localStorage恢复用户状态
|
||||
// 从 localStorage 恢复用户状态
|
||||
const savedToken = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
|
||||
console.log('Store init - savedToken:', savedToken ? savedToken.substring(0, 30) + '...' : 'null')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
token.value = savedToken
|
||||
user.value = JSON.parse(savedUser)
|
||||
|
||||
// 只在开发环境输出详细日志
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('恢复用户状态:', user.value?.username, '角色:', user.value?.role)
|
||||
}
|
||||
console.log('恢复用户状态:', user.value?.username)
|
||||
|
||||
// 刷新用户信息(确保角色等信息是最新的)
|
||||
await fetchCurrentUser()
|
||||
@@ -154,6 +153,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
// 重置初始化状态(登录成功后调用)
|
||||
const resetInitialized = () => {
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
user,
|
||||
@@ -172,6 +176,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
fetchCurrentUser,
|
||||
clearUserData,
|
||||
init,
|
||||
initialized
|
||||
initialized,
|
||||
resetInitialized
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
@@ -162,7 +166,8 @@ import {
|
||||
User as Search,
|
||||
User as Avatar,
|
||||
ArrowDown,
|
||||
Money
|
||||
Money,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend, getSystemStatus } from '@/api/dashboard'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
@@ -229,6 +234,10 @@ const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div v-if="isAdminMode" class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div v-if="isAdminMode" class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
@@ -218,7 +222,20 @@
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">{{ $t('orders.status') }}:</span>
|
||||
<span class="status-tag" :class="getStatusClass(currentOrderDetail.status)">
|
||||
<el-select
|
||||
v-if="isAdminMode"
|
||||
v-model="editingStatus"
|
||||
size="small"
|
||||
style="width: 120px;"
|
||||
@change="handleStatusChange"
|
||||
>
|
||||
<el-option label="待支付" value="PENDING" />
|
||||
<el-option label="已支付" value="PAID" />
|
||||
<el-option label="已完成" value="COMPLETED" />
|
||||
<el-option label="已取消" value="CANCELLED" />
|
||||
<el-option label="已退款" value="REFUNDED" />
|
||||
</el-select>
|
||||
<span v-else class="status-tag" :class="getStatusClass(currentOrderDetail.status)">
|
||||
{{ getStatusText(currentOrderDetail.status) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -300,9 +317,10 @@ import {
|
||||
Delete,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
Loading
|
||||
Loading,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getOrders, getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders } from '@/api/orders'
|
||||
import { getOrders, getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders, updateOrderStatus } from '@/api/orders'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -348,6 +366,10 @@ const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
@@ -502,12 +524,40 @@ const goToPage = (page) => {
|
||||
// 订单详情弹窗相关
|
||||
const orderDetailVisible = ref(false)
|
||||
const currentOrderDetail = ref(null)
|
||||
const editingStatus = ref('')
|
||||
|
||||
const viewOrder = async (order) => {
|
||||
currentOrderDetail.value = order
|
||||
editingStatus.value = order.status || 'PENDING'
|
||||
orderDetailVisible.value = true
|
||||
}
|
||||
|
||||
// 修改订单状态
|
||||
const handleStatusChange = async (newStatus) => {
|
||||
if (!currentOrderDetail.value) return
|
||||
|
||||
try {
|
||||
const response = await updateOrderStatus(currentOrderDetail.value.id, newStatus)
|
||||
if (response.data?.success) {
|
||||
// 更新当前详情
|
||||
currentOrderDetail.value.status = newStatus
|
||||
// 更新列表中的订单
|
||||
const orderIndex = orders.value.findIndex(o => o.id === currentOrderDetail.value.id)
|
||||
if (orderIndex > -1) {
|
||||
orders.value[orderIndex].status = newStatus
|
||||
}
|
||||
ElMessage.success('订单状态更新成功')
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '更新失败')
|
||||
editingStatus.value = currentOrderDetail.value.status // 恢复原状态
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新订单状态失败:', error)
|
||||
ElMessage.error('更新订单状态失败')
|
||||
editingStatus.value = currentOrderDetail.value.status // 恢复原状态
|
||||
}
|
||||
}
|
||||
|
||||
const deleteOrder = async (order) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -1010,6 +1060,10 @@ const fetchSystemStats = async () => {
|
||||
background: #6366f1;
|
||||
}
|
||||
|
||||
.status-tag.refunded {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
margin-right: 12px;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
@@ -126,7 +130,8 @@ import {
|
||||
Document,
|
||||
Setting,
|
||||
Search,
|
||||
ArrowDown
|
||||
ArrowDown,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import api from '@/api/request'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
@@ -162,6 +167,10 @@ const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
754
demo/frontend/src/views/ErrorStatistics.vue
Normal file
754
demo/frontend/src/views/ErrorStatistics.vue
Normal file
@@ -0,0 +1,754 @@
|
||||
<template>
|
||||
<div class="error-statistics-page">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToDashboard">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>{{ $t('nav.dashboard') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMembers">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ $t('nav.members') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToOrders">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>{{ $t('nav.orders') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToAPI">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.apiManagement') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToTasks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="online-users">
|
||||
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
|
||||
</div>
|
||||
<div class="system-uptime">
|
||||
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部搜索栏 -->
|
||||
<header class="top-header">
|
||||
<div class="page-title">
|
||||
<h2>错误类型统计</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<LanguageSwitcher />
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="exitAdmin">
|
||||
{{ $t('admin.exitAdmin') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容包装器 -->
|
||||
<div class="content-wrapper">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon error">
|
||||
<el-icon><Warning /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">总错误数</div>
|
||||
<div class="stat-number">{{ statistics.totalErrors || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon today">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">今日错误</div>
|
||||
<div class="stat-number">{{ statistics.todayErrors || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon week">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">本周错误</div>
|
||||
<div class="stat-number">{{ statistics.weekErrors || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 错误类型分布 -->
|
||||
<div class="charts-section">
|
||||
<div class="chart-card full-width">
|
||||
<div class="chart-header">
|
||||
<h3>错误类型分布</h3>
|
||||
<el-select v-model="selectedDays" @change="loadStatistics" class="days-select">
|
||||
<el-option label="最近7天" :value="7"></el-option>
|
||||
<el-option label="最近30天" :value="30"></el-option>
|
||||
<el-option label="最近90天" :value="90"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="error-type-list">
|
||||
<div
|
||||
v-for="(item, index) in errorTypeStats"
|
||||
:key="item.type"
|
||||
class="error-type-item"
|
||||
>
|
||||
<div class="type-info">
|
||||
<span class="type-name">{{ item.description || item.type }}</span>
|
||||
<span class="type-count">{{ item.count }} 次</span>
|
||||
</div>
|
||||
<div class="type-bar">
|
||||
<div
|
||||
class="type-bar-fill"
|
||||
:style="{ width: getBarWidth(item.count) + '%', backgroundColor: getBarColor(index) }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="type-percentage">{{ getPercentage(item.count) }}%</div>
|
||||
</div>
|
||||
<el-empty v-if="errorTypeStats.length === 0" description="暂无错误数据" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近错误列表 -->
|
||||
<div class="recent-errors-section">
|
||||
<div class="section-header">
|
||||
<h3>最近错误</h3>
|
||||
<el-button type="primary" size="small" @click="loadRecentErrors">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="recentErrors" v-loading="tableLoading" stripe>
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorType" label="错误类型" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTagType(row.errorType)">{{ row.errorType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column prop="taskId" label="任务ID" width="200" />
|
||||
<el-table-column prop="errorMessage" label="错误信息" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="totalErrors"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadErrorLogs"
|
||||
@current-change="loadErrorLogs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Grid,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Document,
|
||||
Setting,
|
||||
ArrowDown,
|
||||
Warning,
|
||||
Clock,
|
||||
Calendar
|
||||
} from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const tableLoading = ref(false)
|
||||
const selectedDays = ref(7)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalErrors = ref(0)
|
||||
const onlineUsers = ref('0')
|
||||
const systemUptime = ref('--')
|
||||
|
||||
// 数据
|
||||
const statistics = ref({})
|
||||
const errorTypeStats = ref([])
|
||||
const recentErrors = ref([])
|
||||
const errorTypes = ref({})
|
||||
|
||||
// 计算总数
|
||||
const totalCount = computed(() => {
|
||||
return errorTypeStats.value.reduce((sum, item) => sum + item.count, 0)
|
||||
})
|
||||
|
||||
// 加载统计数据
|
||||
const loadStatistics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs/statistics', {
|
||||
params: { days: selectedDays.value }
|
||||
})
|
||||
if (res.data.success) {
|
||||
const data = res.data.data || {}
|
||||
statistics.value = {
|
||||
totalErrors: data.totalErrors || 0,
|
||||
todayErrors: data.todayErrors || 0,
|
||||
weekErrors: data.weekErrors || 0
|
||||
}
|
||||
// 处理错误类型统计 - 后端返回的是 errorsByType
|
||||
if (data.errorsByType) {
|
||||
errorTypeStats.value = Object.entries(data.errorsByType).map(([type, count]) => ({
|
||||
type,
|
||||
description: errorTypes.value[type] || type,
|
||||
count
|
||||
})).sort((a, b) => b.count - a.count)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
ElMessage.error('加载统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载错误类型定义
|
||||
const loadErrorTypes = async () => {
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs/types')
|
||||
if (res.data.success) {
|
||||
errorTypes.value = res.data.data || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载错误类型失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载错误日志列表
|
||||
const loadErrorLogs = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs', {
|
||||
params: {
|
||||
page: currentPage.value - 1,
|
||||
size: pageSize.value
|
||||
}
|
||||
})
|
||||
if (res.data.success) {
|
||||
recentErrors.value = res.data.data || []
|
||||
totalErrors.value = res.data.totalElements || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载错误日志失败:', error)
|
||||
ElMessage.error('加载错误日志失败')
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近错误
|
||||
const loadRecentErrors = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs/recent', {
|
||||
params: { limit: 20 }
|
||||
})
|
||||
if (res.data.success) {
|
||||
recentErrors.value = res.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载最近错误失败:', error)
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取进度条宽度
|
||||
const getBarWidth = (count) => {
|
||||
if (totalCount.value === 0) return 0
|
||||
return Math.min((count / totalCount.value) * 100, 100)
|
||||
}
|
||||
|
||||
// 获取百分比
|
||||
const getPercentage = (count) => {
|
||||
if (totalCount.value === 0) return 0
|
||||
return ((count / totalCount.value) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
const getBarColor = (index) => {
|
||||
const colors = ['#f56c6c', '#e6a23c', '#409eff', '#67c23a', '#909399', '#b88230', '#8e44ad', '#16a085']
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取标签类型
|
||||
const getTagType = (errorType) => {
|
||||
const typeMap = {
|
||||
'API_ERROR': 'danger',
|
||||
'TASK_FAILED': 'warning',
|
||||
'PAYMENT_ERROR': 'danger',
|
||||
'AUTH_ERROR': 'info',
|
||||
'SYSTEM_ERROR': 'danger'
|
||||
}
|
||||
return typeMap[errorType] || 'info'
|
||||
}
|
||||
|
||||
// 导航函数
|
||||
const goToDashboard = () => router.push('/admin/dashboard')
|
||||
const goToMembers = () => router.push('/member-management')
|
||||
const goToOrders = () => router.push('/admin/orders')
|
||||
const goToAPI = () => router.push('/api-management')
|
||||
const goToTasks = () => router.push('/generate-task-record')
|
||||
const goToSettings = () => router.push('/system-settings')
|
||||
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'exitAdmin') {
|
||||
router.push('/profile')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统统计数据
|
||||
const fetchSystemStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/online-stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
onlineUsers.value = data.todayVisitors || 0
|
||||
systemUptime.value = data.uptime || '--'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchSystemStats()
|
||||
await loadErrorTypes()
|
||||
await loadStatistics()
|
||||
await loadErrorLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-statistics-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
margin-top: auto;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.sidebar-footer .highlight {
|
||||
color: #409EFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.top-header {
|
||||
background: #ffffff;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.page-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.user-avatar:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 内容包装器 */
|
||||
.content-wrapper {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-icon.error {
|
||||
background: rgba(245, 108, 108, 0.2);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.stat-icon.today {
|
||||
background: rgba(230, 162, 60, 0.2);
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.stat-icon.week {
|
||||
background: rgba(64, 158, 255, 0.2);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stat-icon.users {
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 图表区域 */
|
||||
.charts-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-card.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.days-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
/* 错误类型列表 */
|
||||
.error-type-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.type-info {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.type-count {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.type-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.type-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.type-percentage {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 最近错误区域 */
|
||||
.recent-errors-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
:deep(.el-table) {
|
||||
background: transparent;
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: #f5f5f5;
|
||||
--el-table-row-hover-bg-color: #f5f5f5;
|
||||
--el-table-border-color: #e5e7eb;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f5f5f5 !important;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
:deep(.el-select) {
|
||||
--el-select-input-focus-border-color: #3b82f6;
|
||||
}
|
||||
|
||||
:deep(.el-pagination) {
|
||||
--el-pagination-bg-color: transparent;
|
||||
--el-pagination-text-color: #9ca3af;
|
||||
--el-pagination-button-disabled-bg-color: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
@@ -270,7 +274,8 @@ import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Delete
|
||||
Delete,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { taskStatusApi } from '@/api/taskStatus'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
@@ -314,6 +319,10 @@ const goToAPI = () => {
|
||||
router.push('/api-management')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
class="text-input"
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="input-tips">
|
||||
<div class="tip-item warning">{{ t('video.imageToVideo.tipWarning') }}</div>
|
||||
<div class="tip-item">{{ t('video.imageToVideo.tip1') }}</div>
|
||||
</div>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? t('video.imageToVideo.optimizing') : t('video.imageToVideo.optimizePrompt') }}
|
||||
@@ -83,6 +87,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 高清模式暂时屏蔽
|
||||
<div class="setting-item">
|
||||
<label>{{ t('video.imageToVideo.hdMode') }} (1080P)</label>
|
||||
<div class="hd-setting">
|
||||
@@ -90,6 +95,7 @@
|
||||
<span class="cost-text">{{ t('video.imageToVideo.hdModeCost') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
@@ -100,6 +106,7 @@
|
||||
:disabled="!isAuthenticated || isCreatingTask"
|
||||
>
|
||||
{{ !isAuthenticated ? t('video.imageToVideo.pleaseLogin') : isCreatingTask ? t('video.imageToVideo.creatingTask') : t('video.imageToVideo.startGenerate') }}
|
||||
<span v-if="isAuthenticated && !isCreatingTask" class="btn-points">30 <el-icon><Star /></el-icon></span>
|
||||
</button>
|
||||
<div v-if="!isAuthenticated" class="login-tip">
|
||||
<p>{{ t('video.imageToVideo.loginRequired') }}</p>
|
||||
@@ -237,7 +244,7 @@
|
||||
<div class="history-prompt">{{ task.prompt || t('video.imageToVideo.noDescription') }}</div>
|
||||
|
||||
<!-- 预览区域 -->
|
||||
<div class="history-preview">
|
||||
<div class="history-preview" :class="{ 'vertical': task.aspectRatio === '9:16' }">
|
||||
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
|
||||
<div class="queue-text">{{ task.status === 'PENDING' ? t('video.imageToVideo.queuing') : t('video.generating') }}</div>
|
||||
<!-- 排队中:不确定进度条 -->
|
||||
@@ -315,6 +322,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click.stop="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 修改密码(所有登录用户可见) -->
|
||||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
|
||||
@@ -337,7 +348,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
import { getProcessingWorks } from '@/api/userWorks'
|
||||
@@ -354,7 +365,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
|
||||
// 表单数据
|
||||
const inputText = ref('')
|
||||
const aspectRatio = ref('16:9')
|
||||
const duration = ref('10')
|
||||
const duration = ref('15')
|
||||
const hdMode = ref(false)
|
||||
const inProgress = ref(false)
|
||||
|
||||
@@ -465,6 +476,11 @@ const goToSystemSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
showUserMenu.value = false
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToChangePassword = () => {
|
||||
showUserMenu.value = false
|
||||
router.push('/change-password')
|
||||
@@ -498,9 +514,11 @@ const uploadFirstFrame = () => {
|
||||
}
|
||||
|
||||
firstFrameFile.value = file
|
||||
console.log('[Upload] 文件已设置:', file.name, file.size, 'bytes')
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
firstFrameImage.value = e.target.result
|
||||
console.log('[Upload] 图片预览已设置,base64长度:', e.target.result.length)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
@@ -551,6 +569,9 @@ const removeLastFrame = () => {
|
||||
|
||||
// 开始生成视频
|
||||
const startGenerate = async () => {
|
||||
console.log('[StartGenerate] 开始,firstFrameFile:', firstFrameFile.value ? `File(${firstFrameFile.value.name})` : 'null',
|
||||
'firstFrameImage:', firstFrameImage.value ? firstFrameImage.value.substring(0, 30) + '...' : 'null')
|
||||
|
||||
// 检查登录状态
|
||||
if (!userStore.isAuthenticated) {
|
||||
ElMessage.warning(t('video.imageToVideo.pleaseLoginFirst'))
|
||||
@@ -560,6 +581,36 @@ const startGenerate = async () => {
|
||||
|
||||
// 注:允许多任务并发,后端会检查最大任务数限制(最多3个)
|
||||
|
||||
// 如果 firstFrameFile 为空但 firstFrameImage 有值(比如从失败任务恢复),尝试从图片重新加载
|
||||
if (!firstFrameFile.value && firstFrameImage.value) {
|
||||
console.log('[StartGenerate] firstFrameFile 为空,firstFrameImage:', firstFrameImage.value.substring(0, 50))
|
||||
const imageUrl = firstFrameImage.value
|
||||
|
||||
// 检查是否是 base64 数据 URL(用户手动上传的图片)
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
try {
|
||||
// base64 数据 URL,可以直接 fetch
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const fileName = `first_frame_${Date.now()}.${blob.type.split('/')[1] || 'png'}`
|
||||
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
|
||||
console.log('[StartGenerate] Base64图片已成功转换为文件对象:', fileName)
|
||||
} catch (error) {
|
||||
console.error('[StartGenerate] 无法从base64加载图片:', error)
|
||||
// 清空图片显示,让用户重新上传
|
||||
firstFrameImage.value = ''
|
||||
ElMessage.error('图片加载失败,请重新选择图片文件')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 外部 URL(如 COS),清空图片显示,让用户重新上传
|
||||
console.warn('[StartGenerate] 图片是外部URL,清空显示让用户重新上传')
|
||||
firstFrameImage.value = ''
|
||||
ElMessage.error('请重新选择图片文件')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
if (!firstFrameFile.value) {
|
||||
ElMessage.error(t('video.imageToVideo.uploadFirstFrameRequired'))
|
||||
@@ -932,16 +983,49 @@ const downloadHistoryVideo = async (task) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const retryTask = () => {
|
||||
// 重置状态
|
||||
currentTask.value = null
|
||||
inProgress.value = false
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = ''
|
||||
// 重新生成(复用原task_id和已上传的图片)
|
||||
const retryTask = async () => {
|
||||
// 检查是否有失败的任务可以重试
|
||||
if (!currentTask.value || !currentTask.value.taskId) {
|
||||
ElMessage.error('没有可重试的任务')
|
||||
return
|
||||
}
|
||||
|
||||
// 重新开始生成
|
||||
startGenerate()
|
||||
const taskId = currentTask.value.taskId
|
||||
console.log('[Retry] 开始重试任务:', taskId)
|
||||
|
||||
// 显示加载状态
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在重新提交任务...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
try {
|
||||
// 调用后端重试API,复用原task_id和已上传的图片
|
||||
const response = await imageToVideoApi.retryTask(taskId)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
// 更新任务状态
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
|
||||
ElMessage.success('重试任务已提交')
|
||||
|
||||
// 开始轮询任务状态
|
||||
startPollingTask()
|
||||
} else {
|
||||
const errorMsg = response.data?.message || '重试失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Retry] 重试任务失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || error.message || '重试失败')
|
||||
} finally {
|
||||
loading.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿功能(已移除按钮,保留函数已删除)
|
||||
@@ -1208,8 +1292,19 @@ const restoreProcessingTask = async () => {
|
||||
|
||||
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
|
||||
if (work.thumbnailUrl) {
|
||||
firstFrameImage.value = processHistoryUrl(work.thumbnailUrl)
|
||||
const imageUrl = processHistoryUrl(work.thumbnailUrl)
|
||||
firstFrameImage.value = imageUrl
|
||||
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
|
||||
// 尝试从URL加载图片并转换为File对象,以便重新生成时可以提交
|
||||
try {
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const fileName = `first_frame_${work.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
|
||||
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
|
||||
console.log('[Task Restore] 首帧图片已转换为文件对象:', fileName)
|
||||
} catch (error) {
|
||||
console.warn('[Task Restore] 无法从URL加载首帧图片:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有真正在进行中的任务才恢复和显示消息
|
||||
@@ -1263,7 +1358,18 @@ const checkLastTaskStatus = async () => {
|
||||
}
|
||||
// 恢复首帧图片(参考图)
|
||||
if (lastTask.firstFrameUrl) {
|
||||
firstFrameImage.value = processHistoryUrl(lastTask.firstFrameUrl)
|
||||
const imageUrl = processHistoryUrl(lastTask.firstFrameUrl)
|
||||
firstFrameImage.value = imageUrl
|
||||
// 尝试从URL加载图片并转换为File对象,以便重试时可以提交
|
||||
try {
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const fileName = `first_frame_${lastTask.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
|
||||
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
|
||||
console.log('[Last Task] 首帧图片已转换为文件对象:', fileName)
|
||||
} catch (error) {
|
||||
console.warn('[Last Task] 无法从URL加载首帧图片:', error)
|
||||
}
|
||||
}
|
||||
// 恢复其他参数
|
||||
if (lastTask.aspectRatio) {
|
||||
@@ -1282,14 +1388,26 @@ const checkLastTaskStatus = async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
// 处理"做同款"传递的路由参数
|
||||
if (route.query.prompt || route.query.referenceImage) {
|
||||
console.log('[做同款] 接收参数:', route.query)
|
||||
|
||||
if (route.query.prompt) {
|
||||
inputText.value = route.query.prompt
|
||||
}
|
||||
if (route.query.aspectRatio) {
|
||||
aspectRatio.value = route.query.aspectRatio
|
||||
}
|
||||
if (route.query.duration) {
|
||||
duration.value = route.query.duration
|
||||
}
|
||||
|
||||
// 处理参考图
|
||||
if (route.query.referenceImage) {
|
||||
firstFrameImage.value = route.query.referenceImage
|
||||
console.log('[做同款] 设置参考图:', route.query.referenceImage)
|
||||
// 注意:firstFrameFile 为 null,用户需要重新上传或点击生成时会提示
|
||||
}
|
||||
|
||||
ElMessage.success(t('video.imageToVideo.historyParamsFilled') || '已填充历史参数')
|
||||
// 清除URL中的query参数,避免刷新页面重复填充
|
||||
router.replace({ path: route.path })
|
||||
@@ -1679,6 +1797,27 @@ onUnmounted(() => {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.input-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tip-item:first-child {
|
||||
color: #60a5fa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optimize-btn {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -1789,6 +1928,21 @@ onUnmounted(() => {
|
||||
background: #6b7280;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-points {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-points .el-icon {
|
||||
color: #60a5fa;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -1883,7 +2037,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
@@ -1891,11 +2044,11 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
align-self: flex-start;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.preview-content:hover {
|
||||
@@ -1904,13 +2057,10 @@ onUnmounted(() => {
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@@ -1928,6 +2078,10 @@ onUnmounted(() => {
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
min-height: 300px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
@@ -2043,18 +2197,14 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
|
||||
/* 任务描述样式 */
|
||||
/* 任务描述样式 - 和历史记录提示词一致 */
|
||||
.task-description {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 15px 0;
|
||||
color: #e5e7eb;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 视频预览容器 */
|
||||
@@ -2378,10 +2528,8 @@ onUnmounted(() => {
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.history-status-checkbox {
|
||||
@@ -2430,10 +2578,13 @@ onUnmounted(() => {
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.history-preview {
|
||||
width: 100%;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
@@ -2442,6 +2593,22 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.history-preview.vertical {
|
||||
aspect-ratio: auto;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-preview.vertical .history-video-thumbnail,
|
||||
.history-preview.vertical .history-placeholder {
|
||||
aspect-ratio: 9/16;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.history-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -267,6 +267,15 @@ const handleLogin = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 登录前清除旧的token,避免过期token影响新的登录请求
|
||||
const oldToken = localStorage.getItem('token')
|
||||
console.log('登录前旧token:', oldToken ? oldToken.substring(0, 50) + '...' : 'null')
|
||||
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
userStore.token = null
|
||||
userStore.user = null
|
||||
|
||||
try {
|
||||
console.log('开始登录... 登录方式:', loginType)
|
||||
|
||||
@@ -308,11 +317,22 @@ const handleLogin = async () => {
|
||||
const loginToken = response.data.data.token
|
||||
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
|
||||
|
||||
console.log('登录成功,新token:', loginToken ? loginToken.substring(0, 50) + '...' : 'null')
|
||||
console.log('新旧token是否相同:', oldToken === loginToken)
|
||||
|
||||
localStorage.setItem('token', loginToken)
|
||||
localStorage.setItem('user', JSON.stringify(loginUser))
|
||||
userStore.user = loginUser
|
||||
userStore.token = loginToken
|
||||
|
||||
// 重置初始化状态,确保路由守卫使用新 token
|
||||
userStore.resetInitialized()
|
||||
|
||||
// 验证保存是否成功
|
||||
const savedToken = localStorage.getItem('token')
|
||||
console.log('验证localStorage中的token:', savedToken ? savedToken.substring(0, 50) + '...' : 'null')
|
||||
console.log('token保存成功:', savedToken === loginToken)
|
||||
|
||||
// 根据后端返回的标记设置是否需要修改密码
|
||||
if (needsPasswordChange) {
|
||||
localStorage.setItem('needSetPassword', '1')
|
||||
@@ -332,8 +352,9 @@ const handleLogin = async () => {
|
||||
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
|
||||
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
|
||||
|
||||
// 使用replace而不是push,避免浏览器历史记录问题
|
||||
await router.replace(redirectPath)
|
||||
// 强制刷新页面,确保所有组件使用新token
|
||||
window.location.href = redirectPath
|
||||
return
|
||||
|
||||
console.log('路由跳转完成')
|
||||
} else {
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
@@ -216,9 +220,12 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="会员等级" prop="level">
|
||||
<el-select v-model="editForm.level" placeholder="请选择会员等级">
|
||||
<el-option label="免费会员" value="免费会员" />
|
||||
<el-option label="标准会员" value="标准会员" />
|
||||
<el-option label="专业会员" value="专业会员" />
|
||||
<el-option
|
||||
v-for="level in membershipLevels"
|
||||
:key="level.id"
|
||||
:label="level.displayName"
|
||||
:value="level.displayName"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="剩余资源点" prop="points">
|
||||
@@ -261,7 +268,8 @@ import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Delete
|
||||
Delete,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as memberAPI from '@/api/members'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
@@ -318,6 +326,9 @@ const editRules = {
|
||||
// 会员数据
|
||||
const memberList = ref([])
|
||||
|
||||
// 会员等级列表(从API动态获取)
|
||||
const membershipLevels = ref([])
|
||||
|
||||
// 导航功能
|
||||
const goToDashboard = () => {
|
||||
router.push('/admin/dashboard')
|
||||
@@ -335,6 +346,10 @@ const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
@@ -714,9 +729,23 @@ const fetchCurrentUserRole = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会员等级列表
|
||||
const loadMembershipLevels = async () => {
|
||||
try {
|
||||
const response = await memberAPI.getMembershipLevels()
|
||||
if (response.data && response.data.success) {
|
||||
membershipLevels.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载会员等级失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取当前用户角色
|
||||
fetchCurrentUserRole()
|
||||
// 加载会员等级列表
|
||||
loadMembershipLevels()
|
||||
// 初始化数据
|
||||
loadMembers()
|
||||
fetchSystemStats()
|
||||
|
||||
@@ -33,14 +33,17 @@
|
||||
<div class="nav-item" @click="goToTextToVideo">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>{{ t('works.textToVideo') }}</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToImageToVideo">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>{{ t('works.imageToVideo') }}</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToStoryboardVideo">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>{{ t('works.storyboardVideo') }}</span>
|
||||
<span class="badge-max">Max</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -122,10 +125,10 @@
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter.native="reload"
|
||||
@keyup.enter.native="doSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
<el-icon class="search-icon" @click="doSearch"><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
@@ -405,6 +408,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 修改密码 -->
|
||||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
|
||||
@@ -424,7 +431,7 @@
|
||||
import { ref, onMounted, onActivated, computed, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Star, User, Compass, Document, VideoPlay, Picture, Film, Search, MoreFilled, Loading, ArrowUp, VideoCamera, Refresh, Delete, CopyDocument, Download, Close, Setting, Lock } from '@element-plus/icons-vue'
|
||||
import { Star, User, Compass, Document, VideoPlay, Picture, Film, Search, MoreFilled, Loading, ArrowUp, VideoCamera, Refresh, Delete, CopyDocument, Download, Close, Setting, Lock, Warning } from '@element-plus/icons-vue'
|
||||
import { getMyWorks, getWorkDetail, deleteWork, recordDownload, getWorkFileUrl } from '@/api/userWorks'
|
||||
import { getCurrentUser } from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -471,6 +478,7 @@ const resolution = ref(null)
|
||||
const sortBy = ref(null)
|
||||
const order = ref('desc')
|
||||
const keyword = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const multiSelect = ref(false)
|
||||
const selectedIds = ref(new Set())
|
||||
|
||||
@@ -584,6 +592,13 @@ const goToMembers = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
showUserMenu.value = false
|
||||
if (userStore.isAdmin) {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
showUserMenu.value = false
|
||||
if (userStore.isAdmin) {
|
||||
@@ -763,9 +778,9 @@ const filteredItems = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// 按关键词筛选
|
||||
if (keyword.value) {
|
||||
const keywordLower = keyword.value.toLowerCase()
|
||||
// 按关键词筛选(只有点击搜索或回车后才生效)
|
||||
if (searchKeyword.value) {
|
||||
const keywordLower = searchKeyword.value.toLowerCase()
|
||||
filtered = filtered.filter(item =>
|
||||
item.title.toLowerCase().includes(keywordLower) ||
|
||||
item.id.includes(keywordLower)
|
||||
@@ -781,6 +796,11 @@ const reload = () => {
|
||||
loadList()
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
const doSearch = () => {
|
||||
searchKeyword.value = keyword.value
|
||||
}
|
||||
|
||||
// 筛选变化时的处理
|
||||
const onFilterChange = () => {
|
||||
// 筛选是响应式的,不需要额外处理
|
||||
@@ -985,11 +1005,19 @@ const createSimilar = (item) => {
|
||||
duration: item.duration || ''
|
||||
}
|
||||
|
||||
// 添加参考图(图生视频需要,分镜图的 cover 是生成结果不传递)
|
||||
// 分镜图的 cover 是生成的分镜图,不是用户上传的原始参考图
|
||||
if (item.category !== '分镜图' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
|
||||
query.referenceImage = item.cover
|
||||
}
|
||||
|
||||
console.log('[做同款] 跳转参数:', query, 'category:', item.category)
|
||||
|
||||
if (item.category === '文生视频') {
|
||||
router.push({ path: '/text-to-video/create', query })
|
||||
} else if (item.category === '图生视频') {
|
||||
router.push({ path: '/image-to-video/create', query })
|
||||
} else if (item.category === '分镜视频') {
|
||||
} else if (item.category === '分镜视频' || item.category === '分镜图') {
|
||||
router.push({ path: '/storyboard-video/create', query })
|
||||
} else {
|
||||
// 默认跳转到文生视频
|
||||
@@ -1274,6 +1302,7 @@ const resetFilters = () => {
|
||||
resolution.value = null
|
||||
sortBy.value = null
|
||||
keyword.value = ''
|
||||
searchKeyword.value = ''
|
||||
ElMessage.success(t('works.filtersReset'))
|
||||
}
|
||||
|
||||
@@ -1486,6 +1515,10 @@ const loadUserInfo = async () => {
|
||||
ElMessage.error(t('profile.loadUserInfoFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
// 401错误由axios拦截器处理,不重复提示
|
||||
if (error.response?.status === 401) {
|
||||
return
|
||||
}
|
||||
console.error('加载用户信息失败:', error)
|
||||
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
|
||||
}
|
||||
@@ -1846,6 +1879,15 @@ onActivated(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.search-icon:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 筛选行:下拉框与重置按钮统一样式 - 匹配设计稿 */
|
||||
.filters-bar :deep(.el-select__wrapper),
|
||||
.filters-bar :deep(.el-button) {
|
||||
@@ -2512,6 +2554,24 @@ onActivated(() => {
|
||||
background-color: #1a1a1a !important;
|
||||
border-color: #333 !important;
|
||||
}
|
||||
|
||||
/* Sora2.0 SVG 风格标签 */
|
||||
.badge-pro, .badge-max {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
background: rgba(62, 163, 255, 0.2);
|
||||
color: #5AE0FF;
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.badge-max {
|
||||
background: rgba(255, 100, 150, 0.2);
|
||||
color: #FF7EB3;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 非 scoped 样式用于 @keyframes 动画 -->
|
||||
|
||||
@@ -30,17 +30,20 @@
|
||||
|
||||
<!-- 工具菜单 -->
|
||||
<nav class="tools-menu">
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" @click="goToTextToVideo">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span @click="goToTextToVideo">{{ t('home.textToVideo') }}</span>
|
||||
<span>{{ t('home.textToVideo') }}</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" @click="goToImageToVideo">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span @click="goToImageToVideo">{{ t('home.imageToVideo') }}</span>
|
||||
<span>{{ t('home.imageToVideo') }}</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" @click="goToStoryboardVideo">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</span>
|
||||
<span>{{ t('home.storyboardVideo') }}</span>
|
||||
<span class="badge-max">Max</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -251,6 +254,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click.stop="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 修改密码(所有登录用户可见) -->
|
||||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer; pointer-events: auto;">
|
||||
@@ -281,7 +288,8 @@ import {
|
||||
Compass,
|
||||
VideoPlay,
|
||||
Picture,
|
||||
Film
|
||||
Film,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getMyWorks } from '@/api/userWorks'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
@@ -393,6 +401,14 @@ const goToMembers = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到错误统计
|
||||
const goToErrorStats = () => {
|
||||
showUserMenu.value = false
|
||||
if (userStore.isAdmin) {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到系统设置
|
||||
const goToSettings = () => {
|
||||
showUserMenu.value = false
|
||||
@@ -438,12 +454,12 @@ const openDetail = async (item) => {
|
||||
const work = response.data.data
|
||||
selectedItem.value = transformWorkData(work)
|
||||
} else {
|
||||
console.error('获取作品详情失败:', response?.data?.message || '未知错误')
|
||||
ElMessage.error(t('profile.loadDetailFailed'))
|
||||
// 如果API返回失败但item已有数据,只记录日志不显示错误
|
||||
console.warn('获取作品详情API返回异常,使用列表数据:', response?.data?.message || '未知错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载作品详情失败:', error)
|
||||
ElMessage.error(t('profile.loadDetailFailed') + ': ' + (error.message || '未知错误'))
|
||||
// 如果API调用失败但item已有数据,只记录日志不显示错误
|
||||
console.warn('加载作品详情API失败,使用列表数据:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,10 +497,35 @@ const formatDuration = (dur) => {
|
||||
// 做同款
|
||||
const createSimilar = (item) => {
|
||||
if (!item) return
|
||||
if (item.type === 'video') {
|
||||
router.push('/text-to-video/create')
|
||||
|
||||
// 构建查询参数
|
||||
const query = {
|
||||
prompt: item.prompt || '',
|
||||
aspectRatio: item.aspectRatio || '',
|
||||
duration: item.duration || ''
|
||||
}
|
||||
|
||||
// 添加参考图(分镜图的 cover 是生成结果,不是原始参考图,不传递)
|
||||
if (item.category !== '分镜图' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
|
||||
query.referenceImage = item.cover
|
||||
}
|
||||
|
||||
console.log('[做同款] 跳转参数:', query, 'category:', item.category)
|
||||
|
||||
// 根据作品类型跳转
|
||||
if (item.category === '文生视频') {
|
||||
router.push({ path: '/text-to-video/create', query })
|
||||
} else if (item.category === '图生视频') {
|
||||
router.push({ path: '/image-to-video/create', query })
|
||||
} else if (item.category === '分镜视频' || item.category === '分镜图') {
|
||||
router.push({ path: '/storyboard-video/create', query })
|
||||
} else {
|
||||
router.push('/image-to-video/create')
|
||||
// 默认根据类型跳转
|
||||
if (item.type === 'video') {
|
||||
router.push({ path: '/text-to-video/create', query })
|
||||
} else {
|
||||
router.push({ path: '/image-to-video/create', query })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +554,7 @@ const transformWorkData = (work) => {
|
||||
const thumbnailUrl = processUrl(work.thumbnailUrl)
|
||||
|
||||
return {
|
||||
id: work.id?.toString() || work.taskId || '',
|
||||
id: work.taskId || work.id?.toString() || '',
|
||||
title: work.title || work.prompt || '未命名作品',
|
||||
cover: thumbnailUrl || resultUrl || '/images/backgrounds/welcome.jpg',
|
||||
resultUrl: resultUrl || '',
|
||||
@@ -580,6 +621,10 @@ const loadUserInfo = async () => {
|
||||
ElMessage.error(t('profile.loadUserInfoFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
// 401错误由axios拦截器处理,不重复提示
|
||||
if (error.response?.status === 401) {
|
||||
return
|
||||
}
|
||||
console.error('加载用户信息失败:', error)
|
||||
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
@@ -1602,6 +1647,24 @@ onUnmounted(() => {
|
||||
border-radius: 2px;
|
||||
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Sora2.0 SVG 风格标签 */
|
||||
.badge-pro, .badge-max {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
background: rgba(62, 163, 255, 0.2);
|
||||
color: #5AE0FF;
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.badge-max {
|
||||
background: rgba(255, 100, 150, 0.2);
|
||||
color: #FF7EB3;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 非 scoped 样式用于 @keyframes 动画 -->
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<div class="input-error" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 确定按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -128,7 +129,8 @@ const handleSubmit = async () => {
|
||||
method: 'post',
|
||||
data: {
|
||||
oldPassword: null,
|
||||
newPassword: form.newPassword
|
||||
newPassword: form.newPassword,
|
||||
isFirstTimeSetup: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -286,6 +288,13 @@ onMounted(() => {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 确定按钮 */
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
|
||||
@@ -22,14 +22,17 @@
|
||||
<div class="nav-item" @click="goToTextToVideo">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>文生视频</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToImageToVideo">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图生视频</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>分镜视频</span>
|
||||
<span class="badge-max">Max</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -791,6 +794,24 @@ onMounted(() => {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Sora2.0 SVG 风格标签 */
|
||||
.badge-pro, .badge-max {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
background: rgba(62, 163, 255, 0.2);
|
||||
color: #5AE0FF;
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.badge-max {
|
||||
background: rgba(255, 100, 150, 0.2);
|
||||
color: #FF7EB3;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click.stop="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 修改密码(所有登录用户可见) -->
|
||||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
|
||||
@@ -166,8 +170,9 @@
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="input-tips">
|
||||
<div class="tip-item warning">{{ t('video.storyboard.tipWarning') }}</div>
|
||||
<div class="tip-item">{{ t('video.storyboard.tip1') }}</div>
|
||||
<div class="tip-item">{{ t('video.storyboard.tip2') }}</div>
|
||||
<div class="tip-item points">💎 {{ t('video.storyboard.imageCost') }}</div>
|
||||
</div>
|
||||
<div class="optimize-btn">
|
||||
<button type="button" class="optimize-button" @click="handleStep1ButtonClick" :disabled="!inputText.trim() || inProgress">
|
||||
@@ -249,6 +254,7 @@
|
||||
<div class="input-tips">
|
||||
<div class="tip-item">{{ t('video.storyboard.videoTip1') }}</div>
|
||||
<div class="tip-item">{{ t('video.storyboard.videoTip2') }}</div>
|
||||
<div class="tip-item points">💎 {{ t('video.storyboard.videoCost') }}</div>
|
||||
</div>
|
||||
<div class="optimize-btn">
|
||||
<button type="button" class="optimize-button" @click="handleStep2ButtonClick" :disabled="!mainReferenceImage || inProgress">
|
||||
@@ -276,6 +282,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 高清模式暂时屏蔽
|
||||
<div class="setting-item">
|
||||
<label>{{ t('video.storyboard.hdMode') }}</label>
|
||||
<div class="hd-setting">
|
||||
@@ -283,6 +290,7 @@
|
||||
<span class="cost-text">{{ t('video.storyboard.hdCost') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,6 +305,7 @@
|
||||
:disabled="isGenerateButtonDisabled"
|
||||
>
|
||||
{{ isAuthenticated ? getButtonText() : t('video.storyboard.pleaseLogin') }}
|
||||
<span v-if="isAuthenticated" class="btn-points">30 <el-icon><Star /></el-icon></span>
|
||||
</button>
|
||||
<div v-if="!isAuthenticated" class="login-tip-floating">
|
||||
<p>{{ t('video.storyboard.loginRequired') }}</p>
|
||||
@@ -315,9 +324,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 任务描述 -->
|
||||
<div class="task-description" v-if="inputText">
|
||||
<div class="task-description" v-if="currentStep === 'generate' && inputText">
|
||||
{{ inputText }}
|
||||
</div>
|
||||
<div class="task-description" v-else-if="currentStep === 'video' && videoPrompt">
|
||||
{{ videoPrompt }}
|
||||
</div>
|
||||
|
||||
<!-- 视频预览区域 -->
|
||||
<div class="video-preview-container">
|
||||
@@ -534,7 +546,7 @@
|
||||
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks, retryStoryboardTask } from '@/api/storyboardVideo'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
@@ -556,9 +568,9 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
|
||||
const inputText = ref('')
|
||||
const videoPrompt = ref('') // 视频生成提示词
|
||||
const aspectRatio = ref('16:9')
|
||||
const duration = ref('10')
|
||||
const duration = ref('15')
|
||||
const hdMode = ref(false)
|
||||
const imageModel = ref('nano-banana')
|
||||
const imageModel = ref('nano-banana2')
|
||||
const inProgress = ref(false)
|
||||
const currentStep = ref('generate') // 'generate' 或 'video'
|
||||
|
||||
@@ -687,6 +699,11 @@ const goToSystemSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
showUserMenu.value = false
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToChangePassword = () => {
|
||||
showUserMenu.value = false
|
||||
router.push('/change-password')
|
||||
@@ -801,18 +818,6 @@ const getButtonText = () => {
|
||||
return t('video.generate')
|
||||
}
|
||||
// 第一步(生成分镜图):根据是否有参考图和提示词显示不同文本
|
||||
if (hasUploadedImages.value && inputText.value.trim()) {
|
||||
// 有参考图和提示词:显示"生成分镜图"
|
||||
return t('video.storyboard.generateStoryboard')
|
||||
}
|
||||
if (hasUploadedImages.value) {
|
||||
// 只有参考图没有提示词:提示输入描述
|
||||
return t('video.storyboard.generateStoryboard')
|
||||
}
|
||||
if (inputText.value.trim()) {
|
||||
// 只有提示词没有参考图
|
||||
return t('video.storyboard.generateStoryboard')
|
||||
}
|
||||
return t('video.storyboard.generateStoryboard')
|
||||
}
|
||||
|
||||
@@ -1422,6 +1427,7 @@ const pollTaskStatus = async (taskId) => {
|
||||
const maxAttempts = 90 // 最大尝试次数(足够覆盖整个流程)
|
||||
let attempts = 0
|
||||
let currentInterval = 20000 // 初始间隔:20秒(分镜图生成阶段)
|
||||
let promptWaitAttempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
// 先检查是否超过最大尝试次数
|
||||
@@ -1575,21 +1581,30 @@ const pollTaskStatus = async (taskId) => {
|
||||
// 如果进度 < 100,说明只是分镜图完成,视频还没生成
|
||||
// 注意:如果正在生成视频(inProgress=true),不要停止轮询
|
||||
if (taskProgress < 100 && !inProgress.value) {
|
||||
// 检查提示词是否已经获取到(后端异步优化可能稍晚完成)
|
||||
const hasPrompts = task.imagePrompt || task.videoPrompt
|
||||
if (!hasPrompts) {
|
||||
// 提示词还没就绪,继续轮询(缩短间隔)
|
||||
console.log('[轮询] 分镜图已生成但提示词未就绪,继续轮询')
|
||||
const imagePromptStr = task.imagePrompt ? String(task.imagePrompt).trim() : ''
|
||||
const videoPromptStr = task.videoPrompt ? String(task.videoPrompt).trim() : ''
|
||||
const hasImagePrompt = !!imagePromptStr && imagePromptStr !== 'null'
|
||||
const hasVideoPrompt = !!videoPromptStr && videoPromptStr !== 'null'
|
||||
|
||||
currentStep.value = 'video'
|
||||
pollIntervalId.value = setTimeout(poll, 5000) // 5秒后再试
|
||||
|
||||
// videoPrompt 还没就绪时:先用 imagePrompt 临时填充,继续短间隔轮询等待真正 videoPrompt
|
||||
if (!hasVideoPrompt) {
|
||||
if (!videoPrompt.value.trim() && hasImagePrompt) {
|
||||
videoPrompt.value = imagePromptStr
|
||||
}
|
||||
promptWaitAttempts++
|
||||
console.log('[轮询] 分镜图已生成但 videoPrompt 未就绪,继续轮询', { promptWaitAttempts })
|
||||
pollIntervalId.value = setTimeout(poll, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
// videoPrompt 已就绪,可以停止轮询并提示
|
||||
if (pollIntervalId.value) {
|
||||
clearTimeout(pollIntervalId.value)
|
||||
pollIntervalId.value = null
|
||||
}
|
||||
currentStep.value = 'video'
|
||||
promptWaitAttempts = 0
|
||||
|
||||
setTimeout(() => {
|
||||
ElMessage.success(t('video.storyboard.storyboardCompleted'))
|
||||
@@ -2719,14 +2734,29 @@ const checkLastTaskStatus = async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
// 处理"做同款"传递的路由参数
|
||||
if (route.query.prompt || route.query.referenceImage) {
|
||||
console.log('[做同款] 接收参数:', route.query)
|
||||
|
||||
if (route.query.prompt) {
|
||||
inputText.value = route.query.prompt
|
||||
}
|
||||
if (route.query.aspectRatio) {
|
||||
aspectRatio.value = route.query.aspectRatio
|
||||
}
|
||||
if (route.query.duration) {
|
||||
duration.value = route.query.duration
|
||||
}
|
||||
|
||||
// 处理参考图
|
||||
if (route.query.referenceImage) {
|
||||
uploadedImages.value = [{
|
||||
url: route.query.referenceImage,
|
||||
file: null,
|
||||
name: '参考图片'
|
||||
}]
|
||||
console.log('[做同款] 设置参考图:', route.query.referenceImage)
|
||||
}
|
||||
|
||||
ElMessage.success(t('video.storyboardVideo.historyParamsFilled') || '已填充历史参数')
|
||||
// 清除URL中的query参数,避免刷新页面重复填充
|
||||
router.replace({ path: route.path })
|
||||
@@ -3744,6 +3774,11 @@ onBeforeUnmount(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tip-item.points {
|
||||
color: #fbbf24;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optimize-btn {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -3883,7 +3918,6 @@ onBeforeUnmount(() => {
|
||||
background: #6b7280;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -3892,6 +3926,21 @@ onBeforeUnmount(() => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-points {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-points .el-icon {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.login-tip-floating {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
@@ -3963,7 +4012,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
@@ -3971,14 +4019,14 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
min-height: 0; /* 允许flex子项缩小 */
|
||||
position: relative; /* 为绝对定位的子元素提供定位参考 */
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
align-self: flex-start;
|
||||
min-height: 300px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.preview-content:hover {
|
||||
@@ -3987,13 +4035,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@@ -4212,15 +4257,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 任务状态样式 */
|
||||
.task-status {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
min-height: 300px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
@@ -4243,18 +4288,14 @@ onBeforeUnmount(() => {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 任务描述样式 */
|
||||
/* 任务描述样式 - 和历史记录提示词一致 */
|
||||
.task-description {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 15px 0;
|
||||
color: #e5e7eb;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 视频预览容器 */
|
||||
@@ -4636,10 +4677,8 @@ onBeforeUnmount(() => {
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.history-status-checkbox {
|
||||
@@ -4688,10 +4727,13 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.history-preview {
|
||||
width: 100%;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -33,14 +33,17 @@
|
||||
<div class="nav-item" @click="goToTextToVideo">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>{{ $t('home.textToVideo') }}</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToImageToVideo">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>{{ $t('home.imageToVideo') }}</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>{{ $t('home.storyboardVideo') }}</span>
|
||||
<span class="badge-max">Max</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -124,7 +127,7 @@
|
||||
<div class="package-header">
|
||||
<h4 class="package-title">{{ $t('subscription.free') }}</h4>
|
||||
</div>
|
||||
<div class="package-price">${{ membershipPrices.free }}{{ $t('subscription.perMonth') }}</div>
|
||||
<div class="package-price">¥{{ membershipPrices.free }}/年</div>
|
||||
<div class="points-box points-box-placeholder"> </div>
|
||||
<button class="package-button current">{{ $t('subscription.currentPackage') }}</button>
|
||||
<div class="package-features">
|
||||
@@ -132,6 +135,22 @@
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>{{ $t('subscription.freeNewUserBonus') }}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>文生视频:30积分/条</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>图生视频:30积分/条</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>分镜图生成:30积分/次</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>分镜视频生成:30积分/条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +160,7 @@
|
||||
<h4 class="package-title">{{ $t('subscription.standard') }}</h4>
|
||||
<div class="discount-tag">{{ $t('subscription.firstPurchaseDiscount') }}</div>
|
||||
</div>
|
||||
<div class="package-price">${{ membershipPrices.standard }}{{ $t('subscription.perMonth') }}</div>
|
||||
<div class="package-price">¥{{ membershipPrices.standard }}/年</div>
|
||||
<div class="points-box">{{ $t('subscription.standardPoints') }}</div>
|
||||
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">{{ $t('subscription.subscribe') }}</button>
|
||||
<div class="package-features">
|
||||
@@ -153,10 +172,6 @@
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>{{ $t('subscription.commercialUse') }}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>{{ $t('subscription.noWatermark') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +181,7 @@
|
||||
<h4 class="package-title">{{ $t('subscription.professional') }}</h4>
|
||||
<div class="value-tag">{{ $t('subscription.bestValue') }}</div>
|
||||
</div>
|
||||
<div class="package-price">${{ membershipPrices.premium }}{{ $t('subscription.perMonth') }}</div>
|
||||
<div class="package-price">¥{{ membershipPrices.premium }}/年</div>
|
||||
<div class="points-box">{{ $t('subscription.premiumPoints') }}</div>
|
||||
<button class="package-button premium" @click.stop="handleSubscribe('premium')">{{ $t('subscription.subscribe') }}</button>
|
||||
<div class="package-features">
|
||||
@@ -178,10 +193,6 @@
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>{{ $t('subscription.commercialUse') }}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>{{ $t('subscription.noWatermark') }}</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon class="check-icon"><Check /></el-icon>
|
||||
<span>{{ $t('subscription.earlyAccess') }}</span>
|
||||
@@ -213,6 +224,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 修改密码 -->
|
||||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
|
||||
@@ -252,7 +267,7 @@
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ $t('subscription.currentPoints') }}:</span>
|
||||
<span class="stat-value current">{{ (userInfo.points || 0) - (userInfo.frozenPoints || 0) }}</span>
|
||||
<span class="stat-value current">{{ userStore.availablePoints }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,11 +312,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import axios from 'axios'
|
||||
import MyWorks from '@/views/MyWorks.vue'
|
||||
import PaymentModal from '@/components/PaymentModal.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass, Star, Check, Plus, Setting, Lock } from '@element-plus/icons-vue'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass, Star, Check, Plus, Setting, Lock, Warning } from '@element-plus/icons-vue'
|
||||
import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments'
|
||||
import { getPointsHistory } from '@/api/points'
|
||||
import { getMembershipLevels } from '@/api/members'
|
||||
@@ -384,11 +400,18 @@ const isRechargeType = (type) => {
|
||||
return type === '充值' || type.toLowerCase() === 'recharge'
|
||||
}
|
||||
|
||||
// 会员等级价格配置
|
||||
// 会员等级价格配置(从API动态加载,禁止硬编码)
|
||||
const membershipPrices = ref({
|
||||
free: 0,
|
||||
standard: 59,
|
||||
premium: 259
|
||||
standard: null,
|
||||
premium: null
|
||||
})
|
||||
|
||||
// 会员等级积分配置(从API动态加载,禁止硬编码)
|
||||
const membershipPoints = ref({
|
||||
free: 0,
|
||||
standard: null,
|
||||
premium: null
|
||||
})
|
||||
|
||||
// 加载用户订阅信息
|
||||
@@ -507,15 +530,57 @@ const loadUserSubscriptionInfo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会员等级价格配置(使用固定价格)
|
||||
// 加载会员等级价格配置(从公开接口获取,无需登录)
|
||||
const loadMembershipPrices = async () => {
|
||||
// 使用固定价格配置
|
||||
try {
|
||||
// 添加时间戳防止缓存
|
||||
const response = await axios.get('/api/public/config', {
|
||||
params: { _t: Date.now() },
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
})
|
||||
if (response.data) {
|
||||
// 从membershipLevels数组读取价格(必须从数据库获取,禁止硬编码)
|
||||
if (response.data.membershipLevels && response.data.membershipLevels.length > 0) {
|
||||
const levels = response.data.membershipLevels
|
||||
const freeLevel = levels.find(l => l.name === 'free')
|
||||
const standardLevel = levels.find(l => l.name === 'standard')
|
||||
const proLevel = levels.find(l => l.name === 'professional')
|
||||
|
||||
if (!standardLevel || !proLevel) {
|
||||
throw new Error('数据库中缺少standard或professional会员等级配置')
|
||||
}
|
||||
|
||||
membershipPrices.value = {
|
||||
free: freeLevel?.price || 0,
|
||||
standard: standardLevel.price,
|
||||
premium: proLevel.price
|
||||
}
|
||||
// 同时加载积分配置
|
||||
membershipPoints.value = {
|
||||
free: freeLevel?.pointsBonus || 0,
|
||||
standard: standardLevel.pointsBonus,
|
||||
premium: proLevel.pointsBonus
|
||||
}
|
||||
} else {
|
||||
throw new Error('API返回的会员等级数据为空')
|
||||
}
|
||||
console.log('从后端加载会员等级配置:', membershipPrices.value, membershipPoints.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载价格配置失败:', error.message)
|
||||
// 禁止使用硬编码默认值,提示用户检查数据库配置
|
||||
membershipPrices.value = {
|
||||
free: 0,
|
||||
standard: 59,
|
||||
premium: 259
|
||||
standard: null,
|
||||
premium: null
|
||||
}
|
||||
membershipPoints.value = {
|
||||
free: 0,
|
||||
standard: null,
|
||||
premium: null
|
||||
}
|
||||
ElMessage.error('无法加载会员等级价格配置,请检查数据库中membership_levels表是否有数据')
|
||||
}
|
||||
console.log('会员等级价格配置:', membershipPrices.value)
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
@@ -685,6 +750,13 @@ const goToMembers = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
showUserMenu.value = false
|
||||
if (userStore.isAdmin) {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
showUserMenu.value = false
|
||||
if (userStore.isAdmin) {
|
||||
@@ -739,13 +811,9 @@ const handleSubscribe = async (planType) => {
|
||||
|
||||
console.log('支付数据设置完成:', currentPaymentData.value)
|
||||
|
||||
// 显示支付模态框
|
||||
// 显示支付模态框(PaymentModal组件会自动处理支付流程)
|
||||
paymentModalVisible.value = true
|
||||
console.log('支付模态框应该已显示')
|
||||
|
||||
// 直接生成二维码
|
||||
ElMessage.info(t('subscription.generatingQRCode'))
|
||||
await generateQRCode(planType, planInfo)
|
||||
console.log('支付模态框已显示,PaymentModal将自动处理支付')
|
||||
|
||||
} catch (error) {
|
||||
console.error('订阅处理失败:', error)
|
||||
@@ -818,33 +886,36 @@ const generateQRCode = async (planType, planInfo) => {
|
||||
}
|
||||
} else {
|
||||
console.error('支付宝响应失败:', alipayResponse)
|
||||
ElMessage.error(alipayResponse.data?.message || t('subscription.qrCodeGenerationFailed'))
|
||||
// 不显示后端返回的详细错误消息,只显示通用提示
|
||||
ElMessage.error(t('subscription.qrCodeGenerationFailed'))
|
||||
}
|
||||
} else {
|
||||
console.error('创建支付订单失败:', createResponse)
|
||||
ElMessage.error(createResponse.data?.message || t('subscription.createPaymentFailed'))
|
||||
// 不显示后端返回的详细错误消息,只显示通用提示
|
||||
ElMessage.error(t('subscription.createPaymentFailed'))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('=== 二维码生成出错 ===')
|
||||
console.error('错误详情:', error)
|
||||
ElMessage.error(t('subscription.qrCodeGenerationError', { message: error.message || t('subscription.pleaseTryAgain') }))
|
||||
// 不显示详细错误消息,只显示通用提示
|
||||
ElMessage.error(t('subscription.qrCodeGenerationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 获取套餐信息
|
||||
// 获取套餐信息(使用动态价格和积分,禁止硬编码)
|
||||
const getPlanInfo = (planType) => {
|
||||
const plans = {
|
||||
standard: {
|
||||
name: t('subscription.standard'),
|
||||
price: 59,
|
||||
points: 200,
|
||||
price: membershipPrices.value.standard,
|
||||
points: membershipPoints.value.standard,
|
||||
description: t('subscription.standardDescription')
|
||||
},
|
||||
premium: {
|
||||
name: t('subscription.professional'),
|
||||
price: 259,
|
||||
points: 1000,
|
||||
price: membershipPrices.value.premium,
|
||||
points: membershipPoints.value.premium,
|
||||
description: t('subscription.premiumDescription')
|
||||
}
|
||||
}
|
||||
@@ -1557,5 +1628,23 @@ const createSubscriptionOrder = async (planType, planInfo) => {
|
||||
.history-info strong {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sora2.0 SVG 风格标签 */
|
||||
.badge-pro, .badge-max {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
background: rgba(62, 163, 255, 0.2);
|
||||
color: #5AE0FF;
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.badge-max {
|
||||
background: rgba(255, 100, 150, 0.2);
|
||||
color: #FF7EB3;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
@@ -107,8 +111,8 @@
|
||||
<h3>{{ level.name }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="price">${{ level.price || 0 }}{{ $t('systemSettings.perMonth') }}</p>
|
||||
<p class="description">{{ $t('systemSettings.includesPointsPerMonth', { points: level.resourcePoints || level.pointsBonus || 0 }) }}</p>
|
||||
<p class="price">¥{{ level.price || 0 }}/年</p>
|
||||
<p class="description">包含{{ level.resourcePoints || level.pointsBonus || 0 }}积分/年</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<el-button type="primary" @click="editLevel(level)">{{ $t('common.edit') }}</el-button>
|
||||
@@ -305,7 +309,7 @@
|
||||
<el-form :model="editForm" :rules="editRules" ref="editFormRef">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('systemSettings.membershipLevel') }}</label>
|
||||
<el-select v-model="editForm.level" :placeholder="$t('systemSettings.selectLevelPlaceholder')" style="width: 100%;">
|
||||
<el-select v-model="editForm.level" :placeholder="$t('systemSettings.selectLevelPlaceholder')" style="width: 100%;" disabled>
|
||||
<el-option :label="$t('systemSettings.freeMembership')" value="free"></el-option>
|
||||
<el-option :label="$t('systemSettings.standardMembership')" value="standard"></el-option>
|
||||
<el-option :label="$t('systemSettings.professionalMembership')" value="professional"></el-option>
|
||||
@@ -315,7 +319,7 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('systemSettings.membershipPrice') }}</label>
|
||||
<div class="price-input">
|
||||
<span class="price-prefix">$</span>
|
||||
<span class="price-prefix">¥</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="editForm.price"
|
||||
@@ -343,12 +347,12 @@
|
||||
<div class="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
id="monthly"
|
||||
id="yearly"
|
||||
v-model="editForm.validityPeriod"
|
||||
value="monthly"
|
||||
value="yearly"
|
||||
class="radio-input"
|
||||
>
|
||||
<label for="monthly" class="radio-label">{{ $t('systemSettings.monthly') }}</label>
|
||||
<label for="yearly" class="radio-label">{{ $t('systemSettings.yearly') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -431,10 +435,11 @@ import {
|
||||
User as ArrowDown,
|
||||
Delete,
|
||||
Refresh,
|
||||
Check
|
||||
Check,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import api from '@/api/request'
|
||||
import cleanupApi from '@/api/cleanup'
|
||||
import { getMembershipLevels, updateMembershipLevel } from '@/api/members'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -458,7 +463,7 @@ const editForm = reactive({
|
||||
level: '',
|
||||
price: '',
|
||||
resourcePoints: 0,
|
||||
validityPeriod: 'monthly'
|
||||
validityPeriod: 'yearly'
|
||||
})
|
||||
|
||||
const editRules = computed(() => ({
|
||||
@@ -522,6 +527,10 @@ const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
@@ -537,10 +546,10 @@ const handleUserCommand = (command) => {
|
||||
const editLevel = (level) => {
|
||||
// 映射后端数据到前端表单
|
||||
editForm.id = level.id
|
||||
editForm.level = level.name || level.displayName
|
||||
editForm.level = level.key
|
||||
editForm.price = level.price ? String(level.price) : '0'
|
||||
editForm.resourcePoints = level.pointsBonus || level.resourcePoints || 0
|
||||
editForm.validityPeriod = 'monthly' // 默认月付
|
||||
editForm.validityPeriod = 'yearly' // 默认年付
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -561,76 +570,54 @@ const saveEdit = async () => {
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
// 调用后端API更新会员等级配置
|
||||
const updateData = {
|
||||
price: parseFloat(editForm.price),
|
||||
resourcePoints: parseInt(editForm.resourcePoints),
|
||||
pointsBonus: parseInt(editForm.resourcePoints),
|
||||
description: t('systemSettings.includesPointsPerMonth', { points: editForm.resourcePoints })
|
||||
const priceInt = parseInt(editForm.price)
|
||||
if (Number.isNaN(priceInt) || priceInt < 0) {
|
||||
ElMessage.error(t('systemSettings.enterValidNumber'))
|
||||
return
|
||||
}
|
||||
|
||||
await updateMembershipLevel(editForm.id, updateData)
|
||||
|
||||
// 更新本地数据
|
||||
const index = membershipLevels.value.findIndex(level => level.id === editForm.id)
|
||||
if (index !== -1) {
|
||||
membershipLevels.value[index].price = parseFloat(editForm.price)
|
||||
membershipLevels.value[index].pointsBonus = parseInt(editForm.resourcePoints)
|
||||
membershipLevels.value[index].resourcePoints = parseInt(editForm.resourcePoints)
|
||||
membershipLevels.value[index].description = t('systemSettings.includesPointsPerMonth', { points: editForm.resourcePoints })
|
||||
}
|
||||
// 直接更新membership_levels表
|
||||
const updateData = { price: priceInt }
|
||||
console.log('准备更新会员等级:', editForm.id, updateData)
|
||||
const response = await api.put(`/members/levels/${editForm.id}`, updateData)
|
||||
console.log('会员等级更新响应:', response.data)
|
||||
|
||||
if (response.data?.success) {
|
||||
ElMessage.success(t('systemSettings.membershipUpdateSuccess'))
|
||||
editDialogVisible.value = false
|
||||
|
||||
// 重新加载会员等级配置
|
||||
await loadMembershipLevels()
|
||||
} else {
|
||||
ElMessage.error(t('systemSettings.membershipUpdateFailed') + ': ' + (response.data?.message || '未知错误'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update membership level failed:', error)
|
||||
ElMessage.error(t('systemSettings.membershipUpdateFailed') + ': ' + (error.response?.data?.message || error.message))
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会员等级配置
|
||||
// 加载会员等级配置(从membership_levels表读取)
|
||||
const loadMembershipLevels = async () => {
|
||||
loadingLevels.value = true
|
||||
try {
|
||||
const response = await getMembershipLevels()
|
||||
console.log('会员等级配置响应:', response)
|
||||
// 从membership_levels表读取数据
|
||||
const levelsResp = await api.get('/members/levels', {
|
||||
params: { _t: Date.now() },
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
})
|
||||
|
||||
// 检查响应结构
|
||||
let levels = []
|
||||
if (response.data) {
|
||||
if (response.data.success && response.data.data) {
|
||||
levels = response.data.data
|
||||
} else if (Array.isArray(response.data)) {
|
||||
levels = response.data
|
||||
} else if (response.data.data && Array.isArray(response.data.data)) {
|
||||
levels = response.data.data
|
||||
}
|
||||
}
|
||||
|
||||
console.log('解析后的会员等级数据:', levels)
|
||||
|
||||
// 映射后端数据到前端显示格式
|
||||
if (levels.length > 0) {
|
||||
if (levelsResp.data?.success && levelsResp.data?.data) {
|
||||
const levels = levelsResp.data.data
|
||||
membershipLevels.value = levels.map(level => ({
|
||||
id: level.id,
|
||||
key: level.name,
|
||||
name: level.displayName || level.name,
|
||||
price: level.price || 0,
|
||||
resourcePoints: level.pointsBonus || 0,
|
||||
pointsBonus: level.pointsBonus || 0,
|
||||
description: level.description || t('systemSettings.includesPointsPerMonth', { points: level.pointsBonus || 0 })
|
||||
description: t('systemSettings.includesPointsPerMonth', { points: level.pointsBonus || 0 })
|
||||
}))
|
||||
console.log('Membership levels loaded:', membershipLevels.value)
|
||||
} else {
|
||||
// 如果没有数据,使用默认值
|
||||
console.warn('No membership data in database, using defaults')
|
||||
membershipLevels.value = [
|
||||
{ id: 1, name: t('systemSettings.freeMembership'), price: 0, resourcePoints: 200, description: t('systemSettings.includesPointsPerMonth', { points: 200 }) },
|
||||
{ id: 2, name: t('systemSettings.standardMembership'), price: 59, resourcePoints: 500, description: t('systemSettings.includesPointsPerMonth', { points: 500 }) },
|
||||
{ id: 3, name: t('systemSettings.professionalMembership'), price: 250, resourcePoints: 2000, description: t('systemSettings.includesPointsPerMonth', { points: 2000 }) }
|
||||
]
|
||||
throw new Error('获取会员等级数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load membership config failed:', error)
|
||||
@@ -640,12 +627,9 @@ const loadMembershipLevels = async () => {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('systemSettings.unknown')
|
||||
ElMessage.warning(`${t('systemSettings.loadMembershipFailed')}: ${errorMessage}, ${t('systemSettings.usingDefaultConfig')}`)
|
||||
|
||||
// 使用默认值,确保页面可以正常显示
|
||||
membershipLevels.value = [
|
||||
{ id: 1, name: t('systemSettings.freeMembership'), price: 0, resourcePoints: 200, description: t('systemSettings.includesPointsPerMonth', { points: 200 }) },
|
||||
{ id: 2, name: t('systemSettings.standardMembership'), price: 59, resourcePoints: 500, description: t('systemSettings.includesPointsPerMonth', { points: 500 }) },
|
||||
{ id: 3, name: t('systemSettings.professionalMembership'), price: 250, resourcePoints: 2000, description: t('systemSettings.includesPointsPerMonth', { points: 2000 }) }
|
||||
]
|
||||
// API调用失败,清空数据并提示用户检查数据库配置
|
||||
membershipLevels.value = []
|
||||
ElMessage.error('无法加载会员等级配置,请检查数据库中membership_levels表是否有数据')
|
||||
} finally {
|
||||
loadingLevels.value = false
|
||||
}
|
||||
|
||||
@@ -385,10 +385,10 @@ onMounted(() => {
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 用户信息卡片 */
|
||||
@@ -450,7 +450,7 @@ onMounted(() => {
|
||||
.published-works {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.works-tabs {
|
||||
@@ -481,14 +481,14 @@ onMounted(() => {
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-item {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
@@ -570,25 +570,31 @@ onMounted(() => {
|
||||
/* .work-overlay 和 .overlay-text 已移除:不再使用 */
|
||||
|
||||
.work-info {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.work-title {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-meta {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-actions {
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 12px 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -602,7 +608,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.work-director {
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 12px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -619,7 +625,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 高清模式暂时屏蔽
|
||||
<div class="setting-item">
|
||||
<label>{{ t('video.textToVideo.hdMode') }}</label>
|
||||
<div class="hd-setting">
|
||||
@@ -72,6 +73,7 @@
|
||||
<span class="cost-text">{{ t('video.textToVideo.hdModeCost') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
@@ -82,6 +84,7 @@
|
||||
:disabled="!isAuthenticated || isCreatingTask"
|
||||
>
|
||||
{{ !isAuthenticated ? t('video.textToVideo.pleaseLogin') : isCreatingTask ? t('video.textToVideo.creatingTask') : t('video.textToVideo.startGenerate') }}
|
||||
<span v-if="isAuthenticated && !isCreatingTask" class="btn-points">30 <el-icon><Star /></el-icon></span>
|
||||
</button>
|
||||
<div v-if="!isAuthenticated" class="login-tip">
|
||||
<p>{{ t('video.textToVideo.loginRequired') }}</p>
|
||||
@@ -218,7 +221,7 @@
|
||||
<div class="history-prompt">{{ task.prompt || t('video.textToVideo.noDescription') }}</div>
|
||||
|
||||
<!-- 预览区域 -->
|
||||
<div class="history-preview">
|
||||
<div class="history-preview" :class="{ 'vertical': task.aspectRatio === '9:16' }">
|
||||
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
|
||||
<div class="queue-text">{{ task.status === 'PENDING' ? t('video.textToVideo.queuing') : t('video.generating') }}</div>
|
||||
<!-- 排队中:不确定进度条 -->
|
||||
@@ -293,6 +296,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click.stop="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 修改密码(所有登录用户可见) -->
|
||||
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
|
||||
@@ -315,7 +322,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { textToVideoApi } from '@/api/textToVideo'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
import { getProcessingWorks } from '@/api/userWorks'
|
||||
@@ -332,7 +339,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
|
||||
// 响应式数据
|
||||
const inputText = ref('')
|
||||
const aspectRatio = ref('16:9')
|
||||
const duration = ref(10)
|
||||
const duration = ref(15)
|
||||
const hdMode = ref(false)
|
||||
const inProgress = ref(false)
|
||||
const currentTask = ref(null)
|
||||
@@ -435,6 +442,11 @@ const goToSystemSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
showUserMenu.value = false
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToChangePassword = () => {
|
||||
showUserMenu.value = false
|
||||
router.push('/change-password')
|
||||
@@ -801,16 +813,49 @@ const downloadHistoryVideo = async (task) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const retryTask = () => {
|
||||
// 重置状态
|
||||
currentTask.value = null
|
||||
inProgress.value = false
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = ''
|
||||
// 重新生成(复用原task_id)
|
||||
const retryTask = async () => {
|
||||
// 检查是否有失败的任务可以重试
|
||||
if (!currentTask.value || !currentTask.value.taskId) {
|
||||
ElMessage.error('没有可重试的任务')
|
||||
return
|
||||
}
|
||||
|
||||
// 重新开始生成
|
||||
startGenerate()
|
||||
const taskId = currentTask.value.taskId
|
||||
console.log('[Retry] 开始重试任务:', taskId)
|
||||
|
||||
// 显示加载状态
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在重新提交任务...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
try {
|
||||
// 调用后端重试API,复用原task_id
|
||||
const response = await textToVideoApi.retryTask(taskId)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
// 更新任务状态
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
|
||||
ElMessage.success('重试任务已提交')
|
||||
|
||||
// 开始轮询任务状态
|
||||
startPollingTask()
|
||||
} else {
|
||||
const errorMsg = response.data?.message || '重试失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Retry] 重试任务失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || error.message || '重试失败')
|
||||
} finally {
|
||||
loading.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿功能(已移除按钮,保留函数已删除)
|
||||
@@ -1478,6 +1523,21 @@ onUnmounted(() => {
|
||||
background: #6b7280;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-points {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-points .el-icon {
|
||||
color: #60a5fa;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -1572,7 +1632,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
@@ -1580,11 +1639,11 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
align-self: flex-start;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.preview-content:hover {
|
||||
@@ -1593,13 +1652,10 @@ onUnmounted(() => {
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@@ -1706,11 +1762,15 @@ onUnmounted(() => {
|
||||
|
||||
/* 任务状态样式 */
|
||||
.task-status {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
min-height: 300px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
@@ -1796,18 +1856,14 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
|
||||
/* 任务描述样式 */
|
||||
/* 任务描述样式 - 和历史记录提示词一致 */
|
||||
.task-description {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 15px 0;
|
||||
color: #e5e7eb;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 视频预览容器 */
|
||||
@@ -2147,10 +2203,8 @@ onUnmounted(() => {
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.history-status-checkbox {
|
||||
@@ -2199,10 +2253,13 @@ onUnmounted(() => {
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.history-preview {
|
||||
width: 100%;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
@@ -2211,6 +2268,22 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.history-preview.vertical {
|
||||
aspect-ratio: auto;
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-preview.vertical .history-video-thumbnail,
|
||||
.history-preview.vertical .history-placeholder {
|
||||
aspect-ratio: 9/16;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.history-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -249,7 +249,7 @@ const loadVideoData = async () => {
|
||||
const thumbnailUrl = processUrl(work.thumbnailUrl)
|
||||
|
||||
videoData.value = {
|
||||
id: work.id?.toString() || '',
|
||||
id: work.taskId || work.id?.toString() || '',
|
||||
username: work.username || work.user?.username || '未知用户',
|
||||
title: work.title || work.prompt || '未命名作品',
|
||||
description: work.prompt || work.description || '暂无提示词',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DataInitializer.class);
|
||||
|
||||
private final MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
public DataInitializer(MembershipLevelRepository membershipLevelRepository) {
|
||||
this.membershipLevelRepository = membershipLevelRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
initMembershipLevels();
|
||||
}
|
||||
|
||||
private void initMembershipLevels() {
|
||||
if (membershipLevelRepository.count() == 0) {
|
||||
logger.info("初始化会员等级数据...");
|
||||
|
||||
MembershipLevel free = new MembershipLevel();
|
||||
free.setName("free");
|
||||
free.setDisplayName("免费版");
|
||||
free.setPrice(0.0);
|
||||
free.setDurationDays(0);
|
||||
free.setPointsBonus(0);
|
||||
free.setDescription("免费用户");
|
||||
free.setIsActive(true);
|
||||
free.setCreatedAt(LocalDateTime.now());
|
||||
free.setUpdatedAt(LocalDateTime.now());
|
||||
membershipLevelRepository.save(free);
|
||||
|
||||
MembershipLevel standard = new MembershipLevel();
|
||||
standard.setName("standard");
|
||||
standard.setDisplayName("标准会员");
|
||||
standard.setPrice(298.0);
|
||||
standard.setDurationDays(365);
|
||||
standard.setPointsBonus(6000);
|
||||
standard.setDescription("标准会员 - 每年6000积分");
|
||||
standard.setIsActive(true);
|
||||
standard.setCreatedAt(LocalDateTime.now());
|
||||
standard.setUpdatedAt(LocalDateTime.now());
|
||||
membershipLevelRepository.save(standard);
|
||||
|
||||
MembershipLevel professional = new MembershipLevel();
|
||||
professional.setName("professional");
|
||||
professional.setDisplayName("专业版");
|
||||
professional.setPrice(398.0);
|
||||
professional.setDurationDays(365);
|
||||
professional.setPointsBonus(12000);
|
||||
professional.setDescription("专业会员 - 每年12000积分");
|
||||
professional.setIsActive(true);
|
||||
professional.setCreatedAt(LocalDateTime.now());
|
||||
professional.setUpdatedAt(LocalDateTime.now());
|
||||
membershipLevelRepository.save(professional);
|
||||
|
||||
logger.info("✅ 会员等级数据初始化完成,共{}条", membershipLevelRepository.count());
|
||||
} else {
|
||||
logger.info("会员等级数据已存在,跳过初始化");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ public class PayPalConfig {
|
||||
@Value("${paypal.cancel-url:https://vionow.com/api/payment/paypal/cancel}")
|
||||
private String cancelUrl;
|
||||
|
||||
// CNY到USD的汇率配置,默认7.2(即1美元=7.2人民币)
|
||||
@Value("${paypal.exchange-rate:7.2}")
|
||||
private double exchangeRate;
|
||||
|
||||
/**
|
||||
* 创建PayPal API上下文
|
||||
* @return API上下文对象
|
||||
@@ -106,4 +110,8 @@ public class PayPalConfig {
|
||||
public String getCancelUrl() {
|
||||
return cancelUrl;
|
||||
}
|
||||
|
||||
public double getExchangeRate() {
|
||||
return exchangeRate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.TaskStatusRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.service.SystemSettingsService;
|
||||
import com.example.demo.service.OnlineStatsService;
|
||||
@@ -53,6 +55,9 @@ public class AdminController {
|
||||
@Autowired
|
||||
private OnlineStatsService onlineStatsService;
|
||||
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
/**
|
||||
* 给用户增加积分
|
||||
*/
|
||||
@@ -411,6 +416,13 @@ public class AdminController {
|
||||
response.put("maintenanceMode", settings.getMaintenanceMode());
|
||||
response.put("contactEmail", settings.getContactEmail());
|
||||
response.put("tokenExpireHours", settings.getTokenExpireHours());
|
||||
// 套餐价格配置
|
||||
response.put("standardPriceCny", settings.getStandardPriceCny());
|
||||
response.put("proPriceCny", settings.getProPriceCny());
|
||||
response.put("pointsPerGeneration", settings.getPointsPerGeneration());
|
||||
// 支付渠道开关
|
||||
response.put("enableAlipay", settings.getEnableAlipay());
|
||||
response.put("enablePaypal", settings.getEnablePaypal());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
@@ -475,6 +487,52 @@ public class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新套餐价格(同时更新system_settings和membership_levels表)
|
||||
if (settingsData.containsKey("standardPriceCny")) {
|
||||
Object value = settingsData.get("standardPriceCny");
|
||||
Integer price = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
|
||||
settings.setStandardPriceCny(price);
|
||||
// 同步更新membership_levels表
|
||||
membershipLevelRepository.findByName("standard").ifPresent(level -> {
|
||||
level.setPrice(price.doubleValue());
|
||||
level.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
membershipLevelRepository.save(level);
|
||||
logger.info("同步更新membership_levels表: standard价格={}", price);
|
||||
});
|
||||
logger.info("更新标准版价格为: {} 元", price);
|
||||
}
|
||||
if (settingsData.containsKey("proPriceCny")) {
|
||||
Object value = settingsData.get("proPriceCny");
|
||||
Integer price = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
|
||||
settings.setProPriceCny(price);
|
||||
// 同步更新membership_levels表
|
||||
membershipLevelRepository.findByName("professional").ifPresent(level -> {
|
||||
level.setPrice(price.doubleValue());
|
||||
level.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
membershipLevelRepository.save(level);
|
||||
logger.info("同步更新membership_levels表: professional价格={}", price);
|
||||
});
|
||||
logger.info("更新专业版价格为: {} 元", price);
|
||||
}
|
||||
if (settingsData.containsKey("pointsPerGeneration")) {
|
||||
Object value = settingsData.get("pointsPerGeneration");
|
||||
Integer points = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
|
||||
settings.setPointsPerGeneration(points);
|
||||
logger.info("更新每次生成消耗积分为: {}", points);
|
||||
}
|
||||
|
||||
// 更新支付渠道开关
|
||||
if (settingsData.containsKey("enableAlipay")) {
|
||||
Boolean enable = (Boolean) settingsData.get("enableAlipay");
|
||||
settings.setEnableAlipay(enable);
|
||||
logger.info("更新支付宝开关为: {}", enable);
|
||||
}
|
||||
if (settingsData.containsKey("enablePaypal")) {
|
||||
Boolean enable = (Boolean) settingsData.get("enablePaypal");
|
||||
settings.setEnablePaypal(enable);
|
||||
logger.info("更新PayPal开关为: {}", enable);
|
||||
}
|
||||
|
||||
systemSettingsService.update(settings);
|
||||
|
||||
response.put("success", true);
|
||||
|
||||
@@ -85,15 +85,30 @@ public class AuthApiController {
|
||||
return ResponseEntity.badRequest().body(createErrorResponse("邮箱/用户名或密码不正确"));
|
||||
}
|
||||
|
||||
// 获取动态配置的过期时间
|
||||
// 检查用户是否被封禁
|
||||
if (user.getIsActive() == null || !user.getIsActive()) {
|
||||
return ResponseEntity.badRequest().body(createErrorResponse("您的账号已被封禁,请联系管理员"));
|
||||
}
|
||||
|
||||
// 获取动态配置的过期时间(确保不为0或null,默认24小时)
|
||||
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
int expireHours = settings.getTokenExpireHours() != null ? settings.getTokenExpireHours() : 24;
|
||||
int expireHours = (settings.getTokenExpireHours() != null && settings.getTokenExpireHours() > 0) ? settings.getTokenExpireHours() : 24;
|
||||
long expireMs = expireHours * 60L * 60L * 1000L; // 转换为毫秒
|
||||
long expireSeconds = expireHours * 60L * 60L; // 转换为秒
|
||||
|
||||
// 生成JWT Token(为了兼容系统中其他逻辑,使用 user.getUsername() 作为 token subject)
|
||||
logger.info("生成JWT: username={}, expireMs={}, expireHours={}", user.getUsername(), expireMs, expireHours);
|
||||
String token = jwtUtils.generateToken(user.getUsername(), user.getRole(), user.getId(), expireMs);
|
||||
|
||||
// 验证刚生成的token是否有效
|
||||
try {
|
||||
java.util.Date expDate = jwtUtils.getExpirationDateFromToken(token);
|
||||
boolean isExpired = jwtUtils.isTokenExpired(token);
|
||||
logger.info("JWT验证: 过期时间={}, 当前时间={}, 是否过期={}", expDate, new java.util.Date(), isExpired);
|
||||
} catch (Exception e) {
|
||||
logger.error("JWT验证失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 将 token 保存到 Redis
|
||||
redisTokenService.saveToken(user.getUsername(), token, expireSeconds);
|
||||
|
||||
@@ -141,6 +156,12 @@ public class AuthApiController {
|
||||
|
||||
// 查找用户,如果不存在则自动注册
|
||||
User user = userService.findByEmail(email);
|
||||
if (user != null) {
|
||||
// 检查用户是否被封禁
|
||||
if (user.getIsActive() == null || !user.getIsActive()) {
|
||||
return ResponseEntity.badRequest().body(createErrorResponse("您的账号已被封禁,请联系管理员"));
|
||||
}
|
||||
}
|
||||
if (user == null) {
|
||||
// 自动注册新用户
|
||||
try {
|
||||
@@ -207,8 +228,15 @@ public class AuthApiController {
|
||||
// 直接创建用户对象并设置所有必要字段
|
||||
user = new User();
|
||||
|
||||
// 生成唯一用户ID(UID + yyMMdd + 4位随机字符)
|
||||
String generatedUserId = UserIdGenerator.generate();
|
||||
// 生成唯一用户ID(重试最多10次确保唯一性)
|
||||
String generatedUserId = null;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
generatedUserId = UserIdGenerator.generate();
|
||||
if (!userService.existsByUserId(generatedUserId)) {
|
||||
break;
|
||||
}
|
||||
logger.warn("邮箱验证码登录 - 用户ID冲突,重新生成");
|
||||
}
|
||||
user.setUserId(generatedUserId);
|
||||
logger.info("邮箱验证码登录 - 生成用户ID: {}", generatedUserId);
|
||||
|
||||
@@ -234,9 +262,9 @@ public class AuthApiController {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取动态配置的过期时间
|
||||
// 获取动态配置的过期时间(确保不为0或null,默认24小时)
|
||||
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
int expireHours = settings.getTokenExpireHours() != null ? settings.getTokenExpireHours() : 24;
|
||||
int expireHours = (settings.getTokenExpireHours() != null && settings.getTokenExpireHours() > 0) ? settings.getTokenExpireHours() : 24;
|
||||
long expireMs = expireHours * 60L * 60L * 1000L; // 转换为毫秒
|
||||
long expireSeconds = expireHours * 60L * 60L; // 转换为秒
|
||||
|
||||
|
||||
@@ -326,6 +326,49 @@ public class ImageToVideoApiController {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试失败的图生视频任务
|
||||
* 复用原task_id和已上传的图片,重新提交至外部API
|
||||
*/
|
||||
@PostMapping("/tasks/{taskId}/retry")
|
||||
public ResponseEntity<Map<String, Object>> retryTask(
|
||||
@PathVariable String taskId,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 验证用户身份
|
||||
String username = extractUsernameFromToken(token);
|
||||
if (username == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录或token无效");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
logger.info("收到重试任务请求: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 调用重试服务
|
||||
ImageToVideoTask task = imageToVideoService.retryTask(taskId, username);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "重试任务已提交");
|
||||
response.put("data", task);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("重试任务失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
response.put("success", false);
|
||||
response.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("重试任务异常: taskId={}", taskId, e);
|
||||
response.put("success", false);
|
||||
response.put("message", "重试任务失败");
|
||||
return ResponseEntity.internalServerError().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务
|
||||
*/
|
||||
|
||||
@@ -31,12 +31,16 @@ import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/members")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class MemberApiController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MemberApiController.class);
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@@ -241,10 +245,14 @@ public class MemberApiController {
|
||||
String levelName = (String) updateData.get("level");
|
||||
String expiryDateStr = (String) updateData.get("expiryDate");
|
||||
|
||||
logger.info("更新会员等级: userId={}, levelName={}, expiryDate={}", id, levelName, expiryDateStr);
|
||||
|
||||
if (levelName != null) {
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository
|
||||
.findByDisplayName(levelName);
|
||||
|
||||
logger.info("查找会员等级结果: levelName={}, found={}", levelName, levelOpt.isPresent());
|
||||
|
||||
if (levelOpt.isPresent()) {
|
||||
MembershipLevel level = levelOpt.get();
|
||||
|
||||
@@ -271,15 +279,29 @@ public class MemberApiController {
|
||||
// 更新到期时间
|
||||
if (expiryDateStr != null && !expiryDateStr.isEmpty()) {
|
||||
try {
|
||||
// 尝试解析带时间的格式 (如 2025-12-11T17:03:16)
|
||||
java.time.LocalDateTime expiryDateTime = java.time.LocalDateTime.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDateTime);
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
// 尝试解析仅日期格式 (如 2025-12-11)
|
||||
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDate.atStartOfDay());
|
||||
} catch (Exception e) {
|
||||
// 日期格式错误,忽略
|
||||
} catch (Exception e2) {
|
||||
logger.warn("日期格式错误: {}", expiryDateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userMembershipRepository.save(membership);
|
||||
membership.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
UserMembership saved = userMembershipRepository.save(membership);
|
||||
logger.info("✅ 会员等级已保存: userId={}, membershipId={}, levelId={}, levelName={}",
|
||||
user.getId(), saved.getId(), saved.getMembershipLevelId(), level.getDisplayName());
|
||||
} else {
|
||||
logger.warn("❌ 未找到会员等级: levelName={}", levelName);
|
||||
}
|
||||
} else {
|
||||
logger.info("未传入会员等级参数,跳过会员等级更新");
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -27,7 +27,11 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.service.AlipayService;
|
||||
import com.example.demo.service.PaymentService;
|
||||
import com.example.demo.service.UserService;
|
||||
@@ -54,6 +58,12 @@ public class PaymentApiController {
|
||||
@Autowired
|
||||
private JwtUtils jwtUtils;
|
||||
|
||||
@Autowired
|
||||
private UserMembershipRepository userMembershipRepository;
|
||||
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户的支付记录
|
||||
@@ -457,72 +467,63 @@ public class PaymentApiController {
|
||||
String expiryTime = "永久";
|
||||
LocalDateTime paidAt = null;
|
||||
|
||||
// 使用最近的充值记录来确定会员权益
|
||||
if (!allPayments.isEmpty()) {
|
||||
Payment latestPayment = allPayments.get(0);
|
||||
String description = latestPayment.getDescription();
|
||||
paidAt = latestPayment.getPaidAt() != null ?
|
||||
latestPayment.getPaidAt() : latestPayment.getCreatedAt();
|
||||
|
||||
// 从描述或金额中识别套餐类型
|
||||
if (description != null) {
|
||||
if (description.contains("标准版") || description.contains("standard") ||
|
||||
description.contains("Standard") || description.contains("STANDARD")) {
|
||||
currentPlan = "标准版会员";
|
||||
} else if (description.contains("专业版") || description.contains("premium") ||
|
||||
description.contains("Premium") || description.contains("PREMIUM")) {
|
||||
currentPlan = "专业版会员";
|
||||
} else if (description.contains("会员")) {
|
||||
currentPlan = "会员";
|
||||
} else {
|
||||
// 如果描述中没有套餐信息,根据金额判断
|
||||
java.math.BigDecimal amount = latestPayment.getAmount();
|
||||
if (amount != null) {
|
||||
// 标准版订阅 (59-258元) - 200积分
|
||||
if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0 &&
|
||||
amount.compareTo(new java.math.BigDecimal("259.00")) < 0) {
|
||||
currentPlan = "标准版会员";
|
||||
}
|
||||
// 专业版订阅 (259元以上) - 1000积分
|
||||
else if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
|
||||
currentPlan = "专业版会员";
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果描述为空,根据金额判断
|
||||
java.math.BigDecimal amount = latestPayment.getAmount();
|
||||
if (amount != null) {
|
||||
if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0 &&
|
||||
amount.compareTo(new java.math.BigDecimal("259.00")) < 0) {
|
||||
currentPlan = "标准版会员";
|
||||
} else if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
|
||||
currentPlan = "专业版会员";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算到期时间(订阅有效期为1个月)
|
||||
if (paidAt != null && !currentPlan.equals("免费版")) {
|
||||
LocalDateTime expiryDateTime = paidAt.plusMonths(1);
|
||||
// 优先从UserMembership表获取用户的实际会员等级(管理员可能手动修改)
|
||||
try {
|
||||
java.util.Optional<UserMembership> membershipOpt = userMembershipRepository.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
if (membershipOpt.isPresent()) {
|
||||
UserMembership membership = membershipOpt.get();
|
||||
LocalDateTime endDate = membership.getEndDate();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (expiryDateTime.isAfter(now)) {
|
||||
// 未过期,显示到期时间
|
||||
expiryTime = expiryDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
||||
} else {
|
||||
// 已过期,显示已过期
|
||||
if (endDate != null && endDate.isAfter(now)) {
|
||||
// 会员未过期,获取会员等级名称
|
||||
java.util.Optional<MembershipLevel> levelOpt = membershipLevelRepository.findById(membership.getMembershipLevelId());
|
||||
if (levelOpt.isPresent()) {
|
||||
MembershipLevel level = levelOpt.get();
|
||||
currentPlan = level.getDisplayName() != null ? level.getDisplayName() : level.getName();
|
||||
}
|
||||
expiryTime = endDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
||||
paidAt = membership.getStartDate();
|
||||
} else if (endDate != null) {
|
||||
// 会员已过期
|
||||
expiryTime = "已过期";
|
||||
// 如果已过期,恢复为免费版
|
||||
currentPlan = "免费版";
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("从UserMembership获取会员信息失败,将使用支付记录判断: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 如果UserMembership中没有有效会员,尝试从支付记录判断(兼容旧数据)
|
||||
if (currentPlan.equals("免费版") && !allPayments.isEmpty()) {
|
||||
Payment latestPayment = allPayments.get(0);
|
||||
String description = latestPayment.getDescription();
|
||||
LocalDateTime paymentTime = latestPayment.getPaidAt() != null ?
|
||||
latestPayment.getPaidAt() : latestPayment.getCreatedAt();
|
||||
|
||||
// 检查支付是否在一年内
|
||||
if (paymentTime != null && paymentTime.plusYears(1).isAfter(LocalDateTime.now())) {
|
||||
paidAt = paymentTime;
|
||||
// 从描述中识别套餐类型
|
||||
if (description != null) {
|
||||
if (description.contains("专业版") || description.contains("professional") || description.contains("Professional")) {
|
||||
currentPlan = "专业版";
|
||||
} else if (description.contains("标准版") || description.contains("standard") || description.contains("Standard")) {
|
||||
currentPlan = "标准版";
|
||||
}
|
||||
}
|
||||
if (!currentPlan.equals("免费版")) {
|
||||
expiryTime = paymentTime.plusYears(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscriptionInfo.put("currentPlan", currentPlan);
|
||||
subscriptionInfo.put("expiryTime", expiryTime);
|
||||
subscriptionInfo.put("paidAt", paidAt != null ? paidAt.toString() : null);
|
||||
subscriptionInfo.put("points", user.getPoints());
|
||||
subscriptionInfo.put("frozenPoints", user.getFrozenPoints() != null ? user.getFrozenPoints() : 0);
|
||||
subscriptionInfo.put("availablePoints", user.getAvailablePoints());
|
||||
subscriptionInfo.put("username", user.getUsername());
|
||||
subscriptionInfo.put("userId", user.getId());
|
||||
subscriptionInfo.put("email", user.getEmail());
|
||||
@@ -582,8 +583,13 @@ public class PaymentApiController {
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("创建支付宝支付失败", e);
|
||||
// 简化错误消息,避免显示过长的URL
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg != null && errorMsg.length() > 100) {
|
||||
errorMsg = errorMsg.substring(0, 100) + "...";
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("创建支付宝支付失败: " + e.getMessage()));
|
||||
.body(createErrorResponse("创建支付宝支付失败: " + errorMsg));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.service.SystemSettingsService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/public")
|
||||
public class PublicApiController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final SystemSettingsService systemSettingsService;
|
||||
private final MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
public PublicApiController(UserRepository userRepository) {
|
||||
public PublicApiController(UserRepository userRepository, SystemSettingsService systemSettingsService,
|
||||
MembershipLevelRepository membershipLevelRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.systemSettingsService = systemSettingsService;
|
||||
this.membershipLevelRepository = membershipLevelRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开的系统配置(套餐价格等)
|
||||
* 无需登录即可访问
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> getPublicConfig() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
|
||||
// 从membership_levels表读取价格(必须从数据库获取,禁止硬编码)
|
||||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
// 套餐价格配置(从membership_levels表读取)
|
||||
config.put("standardPriceCny", standardPrice);
|
||||
config.put("proPriceCny", proPrice);
|
||||
config.put("standardPoints", standardPoints);
|
||||
config.put("proPoints", proPoints);
|
||||
config.put("pointsPerGeneration", settings.getPointsPerGeneration());
|
||||
|
||||
// 支付渠道开关
|
||||
config.put("enableAlipay", settings.getEnableAlipay());
|
||||
config.put("enablePaypal", settings.getEnablePaypal());
|
||||
|
||||
// 返回所有会员等级列表
|
||||
List<MembershipLevel> levels = membershipLevelRepository.findAll();
|
||||
config.put("membershipLevels", levels.stream().map(level -> {
|
||||
Map<String, Object> levelMap = new HashMap<>();
|
||||
levelMap.put("id", level.getId());
|
||||
levelMap.put("name", level.getName());
|
||||
levelMap.put("displayName", level.getDisplayName());
|
||||
levelMap.put("price", level.getPrice());
|
||||
levelMap.put("pointsBonus", level.getPointsBonus());
|
||||
levelMap.put("durationDays", level.getDurationDays());
|
||||
levelMap.put("description", level.getDescription());
|
||||
return levelMap;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@GetMapping("/users/exists/username")
|
||||
|
||||
@@ -271,18 +271,21 @@ public class TaskStatusApiController {
|
||||
record.put("updatedAt", task.getUpdatedAt());
|
||||
record.put("completedAt", task.getCompletedAt());
|
||||
|
||||
// 计算消耗资源(根据任务类型)
|
||||
// 计算消耗资源(根据任务类型)- 统一30积分
|
||||
String resources = "0积分";
|
||||
if (task.getTaskType() != null) {
|
||||
switch (task.getTaskType()) {
|
||||
case TEXT_TO_VIDEO:
|
||||
resources = "10积分";
|
||||
resources = "30积分";
|
||||
break;
|
||||
case IMAGE_TO_VIDEO:
|
||||
resources = "5积分";
|
||||
resources = "30积分";
|
||||
break;
|
||||
case STORYBOARD_VIDEO:
|
||||
resources = "15积分";
|
||||
resources = "30积分";
|
||||
break;
|
||||
case STORYBOARD_IMAGE:
|
||||
resources = "30积分";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,49 @@ public class TextToVideoApiController {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 重试失败的文生视频任务
|
||||
* 复用原task_id,重新提交至外部API
|
||||
*/
|
||||
@PostMapping("/tasks/{taskId}/retry")
|
||||
public ResponseEntity<Map<String, Object>> retryTask(
|
||||
@PathVariable String taskId,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 验证用户身份
|
||||
String username = extractUsernameFromToken(token);
|
||||
if (username == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录或token无效");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
logger.info("收到重试任务请求: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 调用重试服务
|
||||
TextToVideoTask task = textToVideoService.retryTask(taskId, username);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "重试任务已提交");
|
||||
response.put("data", task);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("重试任务失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
response.put("success", false);
|
||||
response.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("重试任务异常: taskId={}", taskId, e);
|
||||
response.put("success", false);
|
||||
response.put("message", "重试任务失败");
|
||||
return ResponseEntity.internalServerError().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Token中提取用户名
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,8 @@ import com.example.demo.model.UserWork;
|
||||
import com.example.demo.service.UserWorkService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
|
||||
/**
|
||||
* 用户作品API控制器
|
||||
*/
|
||||
@@ -90,7 +92,7 @@ public class UserWorkApiController {
|
||||
*/
|
||||
@GetMapping("/my-works")
|
||||
public ResponseEntity<Map<String, Object>> getMyWorks(
|
||||
@RequestHeader("Authorization") String token,
|
||||
@RequestHeader(value = "Authorization", required = false) String token,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "1000") int size,
|
||||
@RequestParam(defaultValue = "true") boolean includeProcessing) {
|
||||
@@ -719,9 +721,13 @@ public class UserWorkApiController {
|
||||
return username;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (ExpiredJwtException e) {
|
||||
// Token过期是常见情况,不打印堆栈
|
||||
logger.debug("Token已过期");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
logger.error("解析token失败", e);
|
||||
logger.warn("解析token失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ public class Payment {
|
||||
@Column(nullable = false, length = 3)
|
||||
private String currency;
|
||||
|
||||
// PayPal USD转换相关字段
|
||||
@Column(precision = 10, scale = 2)
|
||||
private BigDecimal originalAmount; // 原始人民币金额
|
||||
|
||||
@Column(length = 3)
|
||||
private String originalCurrency; // 原始货币(CNY)
|
||||
|
||||
@Column(precision = 10, scale = 6)
|
||||
private BigDecimal exchangeRate; // 汇率
|
||||
|
||||
@Column(precision = 10, scale = 2)
|
||||
private BigDecimal convertedAmount; // 转换后的USD金额
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private PaymentMethod paymentMethod;
|
||||
@@ -129,6 +142,38 @@ public class Payment {
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public BigDecimal getOriginalAmount() {
|
||||
return originalAmount;
|
||||
}
|
||||
|
||||
public void setOriginalAmount(BigDecimal originalAmount) {
|
||||
this.originalAmount = originalAmount;
|
||||
}
|
||||
|
||||
public String getOriginalCurrency() {
|
||||
return originalCurrency;
|
||||
}
|
||||
|
||||
public void setOriginalCurrency(String originalCurrency) {
|
||||
this.originalCurrency = originalCurrency;
|
||||
}
|
||||
|
||||
public BigDecimal getExchangeRate() {
|
||||
return exchangeRate;
|
||||
}
|
||||
|
||||
public void setExchangeRate(BigDecimal exchangeRate) {
|
||||
this.exchangeRate = exchangeRate;
|
||||
}
|
||||
|
||||
public BigDecimal getConvertedAmount() {
|
||||
return convertedAmount;
|
||||
}
|
||||
|
||||
public void setConvertedAmount(BigDecimal convertedAmount) {
|
||||
this.convertedAmount = convertedAmount;
|
||||
}
|
||||
|
||||
public PaymentMethod getPaymentMethod() {
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ public class PointsFreezeRecord {
|
||||
public enum TaskType {
|
||||
TEXT_TO_VIDEO("文生视频"),
|
||||
IMAGE_TO_VIDEO("图生视频"),
|
||||
STORYBOARD_VIDEO("分镜视频");
|
||||
STORYBOARD_VIDEO("分镜视频"),
|
||||
STORYBOARD_IMAGE("分镜图");
|
||||
|
||||
private final String description;
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ public class SystemSettings {
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Column(nullable = false)
|
||||
private Integer standardPriceCny = 0;
|
||||
private Integer standardPriceCny = 298;
|
||||
|
||||
/** 专业版价格(单位:元) */
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Column(nullable = false)
|
||||
private Integer proPriceCny = 0;
|
||||
private Integer proPriceCny = 398;
|
||||
|
||||
/** 每次生成消耗的资源点数量 */
|
||||
@NotNull
|
||||
|
||||
@@ -70,7 +70,8 @@ public class TaskQueue {
|
||||
public enum TaskType {
|
||||
TEXT_TO_VIDEO("文生视频"),
|
||||
IMAGE_TO_VIDEO("图生视频"),
|
||||
STORYBOARD_VIDEO("分镜视频");
|
||||
STORYBOARD_VIDEO("分镜视频"),
|
||||
STORYBOARD_IMAGE("分镜图");
|
||||
|
||||
private final String description;
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ public class TaskStatus {
|
||||
public enum TaskType {
|
||||
TEXT_TO_VIDEO("文生视频"),
|
||||
IMAGE_TO_VIDEO("图生视频"),
|
||||
STORYBOARD_VIDEO("分镜视频");
|
||||
STORYBOARD_VIDEO("分镜视频"),
|
||||
STORYBOARD_IMAGE("分镜图");
|
||||
|
||||
private final String description;
|
||||
|
||||
|
||||
@@ -247,6 +247,7 @@ public class User {
|
||||
this.bio = bio;
|
||||
}
|
||||
|
||||
|
||||
public Boolean getIsActive() {
|
||||
return isActive;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ import java.util.Optional;
|
||||
@Repository
|
||||
public interface OrderRepository extends JpaRepository<Order, Long> {
|
||||
|
||||
/**
|
||||
* 根据ID查找订单并加载用户信息
|
||||
*/
|
||||
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.id = :id")
|
||||
Optional<Order> findByIdWithUser(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 根据订单号查找订单
|
||||
*/
|
||||
@@ -122,10 +128,9 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
|
||||
BigDecimal sumTotalAmountByCreatedAtBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* 查找需要自动取消的订单
|
||||
* 查找需要自动取消的订单(超过指定时间未支付的PENDING订单)
|
||||
*/
|
||||
@Query("SELECT o FROM Order o WHERE o.status = 'PENDING' AND o.createdAt < :cancelTime")
|
||||
List<Order> findOrdersToAutoCancel(@Param("cancelTime") LocalDateTime cancelTime);
|
||||
List<Order> findByStatusAndCreatedAtBefore(OrderStatus status, LocalDateTime cancelTime);
|
||||
|
||||
// 新增的查询方法
|
||||
|
||||
|
||||
@@ -2,8 +2,19 @@ package com.example.demo.repository;
|
||||
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface SystemSettingsRepository extends JpaRepository<SystemSettings, Long> {
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE SystemSettings s SET s.standardPriceCny = :price WHERE s.id = :id")
|
||||
int updateStandardPrice(@Param("id") Long id, @Param("price") Integer price);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE SystemSettings s SET s.proPriceCny = :price WHERE s.id = :id")
|
||||
int updateProPrice(@Param("id") Long id, @Param("price") Integer price);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,12 +46,6 @@ public interface UserErrorLogRepository extends JpaRepository<UserErrorLog, Long
|
||||
"GROUP BY e.errorType ORDER BY COUNT(e) DESC")
|
||||
List<Object[]> countByErrorType(@Param("startTime") LocalDateTime startTime);
|
||||
|
||||
// 统计:按用户分组统计错误数量
|
||||
@Query("SELECT e.username, COUNT(e) FROM UserErrorLog e " +
|
||||
"WHERE e.createdAt >= :startTime AND e.username IS NOT NULL " +
|
||||
"GROUP BY e.username ORDER BY COUNT(e) DESC")
|
||||
List<Object[]> countByUsername(@Param("startTime") LocalDateTime startTime);
|
||||
|
||||
// 统计:按错误来源分组统计
|
||||
@Query("SELECT e.errorSource, COUNT(e) FROM UserErrorLog e " +
|
||||
"WHERE e.createdAt >= :startTime " +
|
||||
|
||||
@@ -23,4 +23,9 @@ public interface UserMembershipRepository extends JpaRepository<UserMembership,
|
||||
|
||||
// 根据用户ID删除会员信息
|
||||
void deleteByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 查找已过期但状态仍为ACTIVE的会员记录
|
||||
*/
|
||||
java.util.List<UserMembership> findByStatusAndEndDateBefore(String status, LocalDateTime endDate);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
boolean existsByPhone(String phone);
|
||||
boolean existsByUserId(String userId);
|
||||
|
||||
long countByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,11 +21,12 @@ import com.example.demo.model.UserWork;
|
||||
public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
|
||||
/**
|
||||
* 根据用户名查找作品(必须有可显示的内容)
|
||||
* - 分镜视频(STORYBOARD_VIDEO):必须有视频URL(排除只有图片URL的)
|
||||
* - 其他类型:必须有 resultUrl
|
||||
* 根据用户名查找作品(包括正在生成中的作品)
|
||||
* - 排除 DELETED 和 FAILED 状态
|
||||
* - PROCESSING/PENDING 状态的作品即使没有 resultUrl 也要返回
|
||||
* - COMPLETED 状态的分镜视频必须有视频URL(排除只有图片URL的)
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status NOT IN ('DELETED', 'FAILED') AND uw.resultUrl IS NOT NULL AND uw.resultUrl != '' AND (uw.workType != 'STORYBOARD_VIDEO' OR uw.resultUrl NOT LIKE '%.png') ORDER BY uw.createdAt DESC")
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status NOT IN ('DELETED', 'FAILED') AND (uw.status IN ('PROCESSING', 'PENDING') OR (uw.resultUrl IS NOT NULL AND uw.resultUrl != '' AND (uw.workType != 'STORYBOARD_VIDEO' OR uw.resultUrl NOT LIKE '%.png'))) ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.example.demo.scheduler;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.example.demo.service.OrderService;
|
||||
|
||||
/**
|
||||
* 订单定时任务调度器
|
||||
* 处理订单超时自动取消等定时任务
|
||||
*/
|
||||
@Component
|
||||
public class OrderScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(OrderScheduler.class);
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
/**
|
||||
* 自动取消超时订单
|
||||
* 每5分钟执行一次,取消超过30分钟未支付的订单
|
||||
*/
|
||||
@Scheduled(fixedRate = 300000) // 5分钟 = 300000毫秒
|
||||
public void autoCancelTimeoutOrders() {
|
||||
try {
|
||||
logger.debug("开始执行订单超时自动取消任务...");
|
||||
int cancelledCount = orderService.autoCancelTimeoutOrders();
|
||||
if (cancelledCount > 0) {
|
||||
logger.info("订单超时自动取消任务完成,取消了 {} 个订单", cancelledCount);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("订单超时自动取消任务执行异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import com.example.demo.service.StoryboardVideoService;
|
||||
import com.example.demo.service.TaskCleanupService;
|
||||
import com.example.demo.service.TaskQueueService;
|
||||
import com.example.demo.service.TextToVideoService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
|
||||
/**
|
||||
* 任务队列定时调度器
|
||||
@@ -44,6 +46,12 @@ public class TaskQueueScheduler {
|
||||
@Autowired
|
||||
private ImageToVideoService imageToVideoService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private UserMembershipRepository userMembershipRepository;
|
||||
|
||||
/**
|
||||
* 处理待处理任务
|
||||
* 每2分钟执行一次,处理队列中的待处理任务
|
||||
@@ -73,14 +81,14 @@ public class TaskQueueScheduler {
|
||||
boolean hasQueueTasks = taskQueueService.hasTasksToCheck();
|
||||
long processingStatusCount = taskStatusRepository.countByStatus(com.example.demo.model.TaskStatus.Status.PROCESSING);
|
||||
|
||||
logger.info("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}",
|
||||
hasQueueTasks, processingStatusCount);
|
||||
|
||||
if (!hasQueueTasks && processingStatusCount == 0) {
|
||||
// 没有待处理任务,静默跳过轮询
|
||||
// 没有待处理任务,静默跳过轮询(不输出日志)
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}",
|
||||
hasQueueTasks, processingStatusCount);
|
||||
|
||||
// 队列中有任务:检查队列内任务状态
|
||||
if (hasQueueTasks) {
|
||||
logger.info("[轮询调度] 开始检查TaskQueue任务状态");
|
||||
@@ -193,4 +201,19 @@ public class TaskQueueScheduler {
|
||||
logger.error("定期任务清理失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过期会员
|
||||
* 每天凌晨1点执行一次,将过期会员状态改为EXPIRED,清零积分
|
||||
*/
|
||||
@Scheduled(cron = "0 0 1 * * ?")
|
||||
public void processExpiredMemberships() {
|
||||
try {
|
||||
logger.info("开始处理过期会员");
|
||||
int processedCount = userService.processExpiredMemberships(userMembershipRepository);
|
||||
logger.info("处理过期会员完成,处理数量: {}", processedCount);
|
||||
} catch (Exception e) {
|
||||
logger.error("处理过期会员失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import com.example.demo.service.RedisTokenService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -82,14 +83,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String token = jwtUtils.extractTokenFromHeader(authHeader);
|
||||
|
||||
if (token != null && !token.equals("null") && !token.trim().isEmpty()) {
|
||||
logger.info("JWT过滤器: 收到token, 长度={}, 前20字符={}", token.length(), token.substring(0, Math.min(20, token.length())));
|
||||
|
||||
String username = jwtUtils.getUsernameFromToken(token);
|
||||
logger.info("JWT过滤器: 从token提取用户名={}", username);
|
||||
|
||||
if (username != null && jwtUtils.validateToken(token, username)) {
|
||||
logger.info("JWT过滤器: token验证通过, username={}", username);
|
||||
// Redis 验证已降级:isTokenValid 总是返回 true
|
||||
// 主要依赖 JWT 本身的有效性验证
|
||||
User user = userService.findByUsername(username);
|
||||
User user = userService.findByUsernameOrNull(username);
|
||||
|
||||
if (user != null) {
|
||||
// 检查用户是否被封禁
|
||||
if (user.getIsActive() == null || !user.getIsActive()) {
|
||||
logger.warn("用户已被封禁,拒绝访问: {}", username);
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"success\":false,\"code\":403,\"message\":\"您的账号已被封禁,请联系管理员\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建用户权限列表
|
||||
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority(user.getRole()));
|
||||
@@ -105,8 +120,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ExpiredJwtException e) {
|
||||
// Token过期,返回401让前端跳转登录页
|
||||
logger.warn("JWT已过期: 过期时间={}, 当前时间={}, token前30字符={}",
|
||||
e.getClaims().getExpiration(),
|
||||
new java.util.Date(),
|
||||
e.getClaims().getSubject());
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"success\":false,\"code\":401,\"message\":\"登录已过期,请重新登录\"}");
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
logger.error("JWT认证过程中发生异常: {}", e.getMessage(), e);
|
||||
logger.warn("JWT认证失败: {}", e.getMessage());
|
||||
// 清除可能存在的认证信息
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ public class AlipayService {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
@Value("${alipay.app-id}")
|
||||
private String appId;
|
||||
|
||||
@@ -88,6 +91,19 @@ public class AlipayService {
|
||||
logger.error("创建支付订单时发生异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
// 记录支付错误
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"支付宝支付创建失败: " + e.getMessage(),
|
||||
"AlipayService",
|
||||
payment.getOrderId(),
|
||||
"ALIPAY"
|
||||
);
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录支付错误日志失败: {}", logEx.getMessage());
|
||||
}
|
||||
throw new RuntimeException("支付服务异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -228,11 +244,12 @@ public class AlipayService {
|
||||
throw new RuntimeException("二维码生成失败:" + msg + (subMsg != null ? " - " + subMsg : "") + " (code: " + code + (subCode != null ? ", sub_code: " + subCode : "") + ")");
|
||||
}
|
||||
} else {
|
||||
throw new RuntimeException("无法解析响应,响应体:" + responseBody);
|
||||
logger.error("无法解析支付宝响应,响应体:{}", responseBody);
|
||||
throw new RuntimeException("支付宝响应格式异常,请稍后重试");
|
||||
}
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
logger.error("JSON解析失败", e);
|
||||
throw new RuntimeException("JSON解析失败:" + e.getMessage() + ",响应体:" + responseBody);
|
||||
logger.error("JSON解析失败,响应体:{}", responseBody, e);
|
||||
throw new RuntimeException("支付宝响应解析失败,请稍后重试");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -30,6 +30,9 @@ public class CosService {
|
||||
@Autowired
|
||||
private com.example.demo.config.CosConfig cosConfig;
|
||||
|
||||
@Autowired
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
/**
|
||||
* 检查COS是否已启用
|
||||
*/
|
||||
@@ -90,6 +93,7 @@ public class CosService {
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("从URL上传视频到COS失败: {}", videoUrl, e);
|
||||
logFileError(filename, "从URL上传视频到COS失败: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -348,4 +352,22 @@ public class CosService {
|
||||
cosClient.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录文件上传错误
|
||||
*/
|
||||
private void logFileError(String filename, String errorMessage) {
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.FILE_UPLOAD_ERROR,
|
||||
errorMessage,
|
||||
"CosService",
|
||||
null,
|
||||
filename
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.warn("记录文件上传错误日志失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,4 +727,74 @@ public class ImageToVideoService {
|
||||
logger.warn("返还冻结积分失败(可能未冻结): taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试失败的图生视频任务
|
||||
* 复用原task_id和已上传的图片,重新提交至外部API
|
||||
*/
|
||||
@Transactional
|
||||
public ImageToVideoTask retryTask(String taskId, String username) {
|
||||
logger.info("重试失败任务: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 获取任务
|
||||
ImageToVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));
|
||||
|
||||
// 验证权限
|
||||
if (!task.getUsername().equals(username)) {
|
||||
throw new RuntimeException("无权操作此任务");
|
||||
}
|
||||
|
||||
// 验证任务状态必须是 FAILED
|
||||
if (task.getStatus() != ImageToVideoTask.TaskStatus.FAILED) {
|
||||
throw new RuntimeException("只能重试失败的任务,当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 验证图片URL存在
|
||||
if (task.getFirstFrameUrl() == null || task.getFirstFrameUrl().isEmpty()) {
|
||||
throw new RuntimeException("任务缺少首帧图片,无法重试");
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.PENDING);
|
||||
task.setErrorMessage(null);
|
||||
task.setRealTaskId(null); // 清除旧的外部任务ID
|
||||
task.setProgress(0);
|
||||
task.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
|
||||
// 重新添加到任务队列
|
||||
try {
|
||||
taskQueueService.addImageToVideoTask(username, taskId);
|
||||
logger.info("重试任务已添加到队列: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("添加重试任务到队列失败: taskId={}", taskId, e);
|
||||
// 回滚任务状态
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("重试失败: " + e.getMessage());
|
||||
taskRepository.save(task);
|
||||
throw new RuntimeException("重试失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 更新UserWork状态为PROCESSING
|
||||
try {
|
||||
userWorkService.updateWorkStatus(taskId, com.example.demo.model.UserWork.WorkStatus.PROCESSING);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}", taskId, e);
|
||||
}
|
||||
|
||||
// 更新task_status表状态
|
||||
try {
|
||||
taskStatusPollingService.updateTaskStatusWithCascade(
|
||||
taskId,
|
||||
com.example.demo.model.TaskStatus.Status.PROCESSING,
|
||||
null,
|
||||
null
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新task_status状态失败: taskId={}", taskId, e);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,12 @@ import com.example.demo.model.OrderItem;
|
||||
import com.example.demo.model.OrderStatus;
|
||||
import com.example.demo.model.OrderType;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.repository.OrderItemRepository;
|
||||
import com.example.demo.repository.OrderRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@@ -36,6 +40,19 @@ public class OrderService {
|
||||
@Autowired
|
||||
private OrderItemRepository orderItemRepository;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
@Autowired
|
||||
private UserMembershipRepository userMembershipRepository;
|
||||
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
@@ -160,7 +177,8 @@ public class OrderService {
|
||||
*/
|
||||
public Order updateOrderStatus(Long orderId, OrderStatus newStatus) {
|
||||
try {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
// 使用findByIdWithUser确保加载用户信息,用于积分处理
|
||||
Order order = orderRepository.findByIdWithUser(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在:" + orderId));
|
||||
|
||||
OrderStatus oldStatus = order.getStatus();
|
||||
@@ -194,7 +212,6 @@ public class OrderService {
|
||||
order.setCancelledAt(now);
|
||||
break;
|
||||
case REFUNDED:
|
||||
// 已退款状态,不需要设置时间戳
|
||||
break;
|
||||
default:
|
||||
// 其他状态,不需要设置时间戳
|
||||
@@ -205,6 +222,9 @@ public class OrderService {
|
||||
logger.info("订单状态更新成功,订单号:{},状态:{} -> {}",
|
||||
order.getOrderNumber(), oldStatus, newStatus);
|
||||
|
||||
// 处理积分变更
|
||||
handlePointsForStatusChange(order, oldStatus, newStatus);
|
||||
|
||||
return updatedOrder;
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -213,6 +233,194 @@ public class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单状态变更处理用户积分
|
||||
* PENDING/CANCELLED/REFUNDED -> PAID: 增加积分
|
||||
* PAID -> CANCELLED/REFUNDED: 扣除积分
|
||||
*/
|
||||
private void handlePointsForStatusChange(Order order, OrderStatus oldStatus, OrderStatus newStatus) {
|
||||
if (order == null || order.getUser() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
User user = order.getUser();
|
||||
|
||||
// 根据订单描述获取会员等级和对应积分
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
|
||||
if (points <= 0) {
|
||||
logger.info("无法从订单识别会员等级,不处理积分: orderId={}, description={}", order.getId(), order.getDescription());
|
||||
return;
|
||||
}
|
||||
|
||||
// 从非PAID状态变为PAID状态:增加积分并更新会员等级
|
||||
if (newStatus == OrderStatus.PAID && oldStatus != OrderStatus.PAID) {
|
||||
try {
|
||||
userService.addPoints(user.getId(), points);
|
||||
logger.info("✅ 订单状态变更增加积分: userId={}, orderId={}, points={}, {} -> {}",
|
||||
user.getId(), order.getId(), points, oldStatus, newStatus);
|
||||
|
||||
// 更新会员等级和到期时间
|
||||
updateMembershipForOrder(order, user);
|
||||
} catch (Exception e) {
|
||||
logger.error("增加积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 从PAID状态变为CANCELLED或REFUNDED状态:扣除积分
|
||||
if ((newStatus == OrderStatus.CANCELLED || newStatus == OrderStatus.REFUNDED) && oldStatus == OrderStatus.PAID) {
|
||||
try {
|
||||
userService.deductPoints(user.getId(), points);
|
||||
logger.info("✅ 订单状态变更扣除积分: userId={}, orderId={}, points={}, {} -> {}",
|
||||
user.getId(), order.getId(), points, oldStatus, newStatus);
|
||||
} catch (Exception e) {
|
||||
logger.error("扣除积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单描述获取对应会员等级的积分(完全动态,从数据库获取所有会员等级进行匹配)
|
||||
*/
|
||||
private int getPointsFromOrderMembershipLevel(Order order) {
|
||||
String description = order.getDescription();
|
||||
if (description == null || description.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 从数据库获取所有会员等级,动态匹配
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
for (MembershipLevel level : allLevels) {
|
||||
// 检查订单描述是否包含会员等级的name或displayName(不区分大小写)
|
||||
String name = level.getName();
|
||||
String displayName = level.getDisplayName();
|
||||
String descLower = description.toLowerCase();
|
||||
|
||||
if ((name != null && descLower.contains(name.toLowerCase())) ||
|
||||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
|
||||
logger.info("从订单描述匹配到会员等级: level={}, points={}", name, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单金额计算对应的积分
|
||||
*/
|
||||
private int calculatePointsForAmount(BigDecimal amount) {
|
||||
if (amount == null) return 0;
|
||||
|
||||
// 从membership_levels表读取价格和积分(必须从数据库获取,禁止硬编码)
|
||||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
int amountInt = amount.intValue();
|
||||
|
||||
// 判断套餐类型
|
||||
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
|
||||
return proPoints; // 专业版积分
|
||||
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
|
||||
return standardPoints; // 标准版积分
|
||||
} else if (amountInt >= proPrice) {
|
||||
return proPoints;
|
||||
} else if (amountInt >= standardPrice) {
|
||||
return standardPoints;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单信息更新用户会员等级和到期时间
|
||||
*/
|
||||
private void updateMembershipForOrder(Order order, User user) {
|
||||
try {
|
||||
String description = order.getDescription();
|
||||
if (description == null || description.isEmpty()) {
|
||||
logger.info("订单描述为空,不更新会员: userId={}", user.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 从数据库获取所有会员等级,动态匹配
|
||||
MembershipLevel level = null;
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
String descLower = description.toLowerCase();
|
||||
|
||||
for (MembershipLevel lvl : allLevels) {
|
||||
String name = lvl.getName();
|
||||
String displayName = lvl.getDisplayName();
|
||||
|
||||
if ((name != null && descLower.contains(name.toLowerCase())) ||
|
||||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
|
||||
level = lvl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (level == null) {
|
||||
logger.info("无法从订单描述中识别会员等级,不更新会员: userId={}, description={}", user.getId(), description);
|
||||
return;
|
||||
}
|
||||
int durationDays = level.getDurationDays();
|
||||
|
||||
// 查找或创建用户会员信息
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
|
||||
UserMembership membership;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
// 从数据库获取当前会员等级的价格,用价格判断等级高低(价格越高等级越高)
|
||||
// 会员等级只能升不能降:专业版 > 标准版 > 免费版
|
||||
Optional<MembershipLevel> currentLevelOpt = membershipLevelRepository.findById(membership.getMembershipLevelId());
|
||||
Double currentPrice = currentLevelOpt.isPresent() ? currentLevelOpt.get().getPrice() : 0.0;
|
||||
Double newPrice = level.getPrice() != null ? level.getPrice() : 0.0;
|
||||
|
||||
if (newPrice > currentPrice) {
|
||||
// 新等级价格更高,升级会员等级
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
logger.info("会员等级升级: 旧价格={}, 新价格={}", currentPrice, newPrice);
|
||||
}
|
||||
// 延长到期时间
|
||||
LocalDateTime currentEndDate = membership.getEndDate();
|
||||
if (currentEndDate.isAfter(now)) {
|
||||
// 如果还没过期,在当前到期时间基础上延长
|
||||
membership.setEndDate(currentEndDate.plusDays(durationDays));
|
||||
} else {
|
||||
// 如果已过期,从现在开始计算
|
||||
membership.setEndDate(now.plusDays(durationDays));
|
||||
}
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
membership.setStartDate(now);
|
||||
membership.setEndDate(now.plusDays(durationDays));
|
||||
membership.setStatus("ACTIVE");
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
|
||||
logger.info("✅ 更新用户会员信息: userId={}, level={}, endDate={}",
|
||||
user.getId(), level.getName(), membership.getEndDate());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("更新会员信息失败: userId={}", user.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
@@ -578,4 +786,47 @@ public class OrderService {
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动取消超时未支付的订单
|
||||
* 超过30分钟未支付的PENDING订单自动取消
|
||||
*/
|
||||
public int autoCancelTimeoutOrders() {
|
||||
try {
|
||||
// 计算30分钟前的时间
|
||||
LocalDateTime cancelTime = LocalDateTime.now().minusMinutes(30);
|
||||
|
||||
// 查找需要自动取消的订单(状态为PENDING且创建时间超过30分钟)
|
||||
List<Order> timeoutOrders = orderRepository.findByStatusAndCreatedAtBefore(OrderStatus.PENDING, cancelTime);
|
||||
|
||||
if (timeoutOrders.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cancelledCount = 0;
|
||||
for (Order order : timeoutOrders) {
|
||||
try {
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
order.setCancelledAt(LocalDateTime.now());
|
||||
order.setNotes((order.getNotes() != null ? order.getNotes() + "\n" : "") +
|
||||
"系统自动取消:订单超时30分钟未支付");
|
||||
orderRepository.save(order);
|
||||
cancelledCount++;
|
||||
logger.info("订单自动取消成功,订单号:{}", order.getOrderNumber());
|
||||
} catch (Exception e) {
|
||||
logger.error("自动取消订单失败,订单号:{}", order.getOrderNumber(), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelledCount > 0) {
|
||||
logger.info("本次共自动取消 {} 个超时订单", cancelledCount);
|
||||
}
|
||||
|
||||
return cancelledCount;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("自动取消超时订单任务执行失败", e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ public class PayPalService {
|
||||
@Autowired
|
||||
private PayPalConfig payPalConfig;
|
||||
|
||||
@Autowired
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
/**
|
||||
* 创建PayPal支付
|
||||
* @param payment 支付记录
|
||||
@@ -42,12 +45,33 @@ public class PayPalService {
|
||||
|
||||
logger.info("=== 创建PayPal支付 ===");
|
||||
logger.info("订单ID: {}", payment.getOrderId());
|
||||
logger.info("金额: {} {}", payment.getAmount(), payment.getCurrency());
|
||||
logger.info("原始金额: {} {}", payment.getAmount(), payment.getCurrency());
|
||||
|
||||
// 如果是人民币,先转换为美元
|
||||
BigDecimal paypalAmount = payment.getAmount();
|
||||
String paypalCurrency = payment.getCurrency();
|
||||
|
||||
if ("CNY".equalsIgnoreCase(payment.getCurrency())) {
|
||||
// 保存原始人民币金额
|
||||
payment.setOriginalAmount(payment.getAmount());
|
||||
payment.setOriginalCurrency("CNY");
|
||||
payment.setExchangeRate(BigDecimal.valueOf(getExchangeRate()));
|
||||
|
||||
// 转换为美元
|
||||
paypalAmount = convertCNYtoUSD(payment.getAmount());
|
||||
paypalCurrency = "USD";
|
||||
|
||||
// 保存转换后的金额
|
||||
payment.setConvertedAmount(paypalAmount);
|
||||
|
||||
logger.info("PayPal支付金额: {} USD (原始: {} CNY, 汇率: {})",
|
||||
paypalAmount, payment.getAmount(), getExchangeRate());
|
||||
}
|
||||
|
||||
// 创建支付金额
|
||||
Amount amount = new Amount();
|
||||
amount.setCurrency(convertCurrency(payment.getCurrency()));
|
||||
amount.setTotal(formatAmount(payment.getAmount()));
|
||||
amount.setCurrency(paypalCurrency);
|
||||
amount.setTotal(formatAmount(paypalAmount));
|
||||
|
||||
// 创建交易
|
||||
Transaction transaction = new Transaction();
|
||||
@@ -61,8 +85,8 @@ public class PayPalService {
|
||||
|
||||
Item item = new Item();
|
||||
item.setName(payment.getDescription() != null ? payment.getDescription() : "订单");
|
||||
item.setCurrency(convertCurrency(payment.getCurrency()));
|
||||
item.setPrice(formatAmount(payment.getAmount()));
|
||||
item.setCurrency(paypalCurrency);
|
||||
item.setPrice(formatAmount(paypalAmount));
|
||||
item.setQuantity("1");
|
||||
items.add(item);
|
||||
|
||||
@@ -122,9 +146,11 @@ public class PayPalService {
|
||||
logger.error("❌ 创建PayPal支付失败", e);
|
||||
logger.error("错误信息: {}", e.getMessage());
|
||||
logger.error("详细错误: {}", e.getDetails());
|
||||
logPaymentError(payment.getOrderId(), "创建PayPal支付失败: " + e.getMessage());
|
||||
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ 创建PayPal支付失败", e);
|
||||
logPaymentError(payment.getOrderId(), "创建PayPal支付失败: " + e.getMessage());
|
||||
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -245,6 +271,36 @@ public class PayPalService {
|
||||
return currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将人民币金额转换为美元
|
||||
* @param cnyAmount 人民币金额
|
||||
* @return 美元金额
|
||||
*/
|
||||
public BigDecimal convertCNYtoUSD(BigDecimal cnyAmount) {
|
||||
if (cnyAmount == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
double exchangeRate = payPalConfig.getExchangeRate();
|
||||
if (exchangeRate <= 0) {
|
||||
exchangeRate = 7.2; // 默认汇率
|
||||
}
|
||||
BigDecimal usdAmount = cnyAmount.divide(
|
||||
BigDecimal.valueOf(exchangeRate),
|
||||
2,
|
||||
RoundingMode.HALF_UP
|
||||
);
|
||||
logger.info("金额转换: ¥{} CNY -> ${} USD (汇率: {})", cnyAmount, usdAmount, exchangeRate);
|
||||
return usdAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前汇率
|
||||
*/
|
||||
public double getExchangeRate() {
|
||||
double rate = payPalConfig.getExchangeRate();
|
||||
return rate > 0 ? rate : 7.2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
* PayPal要求金额最多2位小数
|
||||
@@ -298,10 +354,30 @@ public class PayPalService {
|
||||
} catch (PayPalRESTException e) {
|
||||
logger.error("❌ PayPal退款失败", e);
|
||||
logger.error("错误信息: {}", e.getMessage());
|
||||
logPaymentError(null, "PayPal退款失败: " + e.getMessage());
|
||||
throw new RuntimeException("PayPal退款失败: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ PayPal退款失败", e);
|
||||
logPaymentError(null, "PayPal退款失败: " + e.getMessage());
|
||||
throw new RuntimeException("PayPal退款失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录支付错误日志
|
||||
*/
|
||||
private void logPaymentError(String orderId, String errorMessage) {
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
errorMessage,
|
||||
"PayPalService",
|
||||
orderId,
|
||||
"PAYPAL"
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.warn("记录支付错误日志失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.example.demo.model.*;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@@ -25,6 +26,8 @@ public class PaymentService {
|
||||
@Autowired private UserService userService;
|
||||
@Autowired private AlipayService alipayService;
|
||||
@Autowired(required = false) private PayPalService payPalService;
|
||||
@Autowired private SystemSettingsService systemSettingsService;
|
||||
@Autowired private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
public Payment save(Payment payment) { return paymentRepository.save(payment); }
|
||||
@Transactional(readOnly = true) public Optional<Payment> findById(Long id) { return paymentRepository.findByIdWithUser(id); }
|
||||
@@ -58,11 +61,11 @@ public class PaymentService {
|
||||
payment.setExternalTransactionId(externalTransactionId);
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
|
||||
// 支付成功后创建订单
|
||||
// 支付成功后更新订单状态为已支付
|
||||
try {
|
||||
createOrderForPayment(savedPayment);
|
||||
updateOrderStatusForPayment(savedPayment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但创建订单失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
logger.error("支付成功但更新订单状态失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 支付成功后增加用户积分
|
||||
@@ -76,61 +79,42 @@ public class PaymentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 为支付成功的记录创建订单
|
||||
* 支付成功后更新关联订单状态为已支付
|
||||
*/
|
||||
private void createOrderForPayment(Payment payment) {
|
||||
if (payment == null || payment.getUser() == null) {
|
||||
logger.warn("无法创建订单: payment或user为空");
|
||||
private void updateOrderStatusForPayment(Payment payment) {
|
||||
if (payment == null) {
|
||||
logger.warn("无法更新订单状态: payment为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经关联了订单
|
||||
if (payment.getOrder() != null) {
|
||||
logger.info("支付记录已关联订单,跳过创建: paymentId={}, orderId={}", payment.getId(), payment.getOrder().getId());
|
||||
Order order = payment.getOrder();
|
||||
if (order == null) {
|
||||
logger.warn("支付记录未关联订单,无法更新状态: paymentId={}", payment.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建订单
|
||||
Order order = new Order();
|
||||
order.setUser(payment.getUser());
|
||||
order.setOrderNumber("ORD" + System.currentTimeMillis() + payment.getId());
|
||||
order.setTotalAmount(payment.getAmount());
|
||||
order.setCurrency(payment.getCurrency() != null ? payment.getCurrency() : "CNY");
|
||||
// 更新订单状态为已支付
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
order.setOrderType(OrderType.SUBSCRIPTION);
|
||||
order.setNotes(payment.getDescription() != null ? payment.getDescription() : "会员订阅");
|
||||
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
|
||||
|
||||
// 根据金额设置订单描述
|
||||
BigDecimal amount = payment.getAmount();
|
||||
if (amount != null) {
|
||||
if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
|
||||
order.setNotes("专业版会员订阅 - " + amount + "元");
|
||||
} else if (amount.compareTo(new BigDecimal("59.00")) >= 0) {
|
||||
order.setNotes("标准版会员订阅 - " + amount + "元");
|
||||
}
|
||||
}
|
||||
|
||||
Order savedOrder = orderService.createOrder(order);
|
||||
|
||||
// 关联支付记录和订单
|
||||
payment.setOrder(savedOrder);
|
||||
paymentRepository.save(payment);
|
||||
|
||||
logger.info("✅ 订单创建成功: orderId={}, orderNumber={}, paymentId={}",
|
||||
savedOrder.getId(), savedOrder.getOrderNumber(), payment.getId());
|
||||
logger.info("✅ 订单状态更新为已支付: orderId={}, orderNumber={}, paymentId={}",
|
||||
order.getId(), order.getOrderNumber(), payment.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建订单失败: paymentId={}, error={}", payment.getId(), e.getMessage(), e);
|
||||
logger.error("更新订单状态失败: paymentId={}, orderId={}, error={}",
|
||||
payment.getId(), order.getId(), e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付金额增加用户积分
|
||||
* 标准版(59元) -> 200积分
|
||||
* 专业版(259元) -> 1000积分
|
||||
* 从 system_settings 读取配置的价格来判断套餐类型
|
||||
* 标准版 -> 6000积分
|
||||
* 专业版 -> 12000积分
|
||||
*/
|
||||
private void addPointsForPayment(Payment payment) {
|
||||
if (payment == null || payment.getUser() == null) {
|
||||
@@ -144,29 +128,48 @@ public class PaymentService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 从membership_levels表读取价格和积分(必须从数据库获取,禁止硬编码)
|
||||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
logger.info("会员等级价格: 标准版={}CNY/{}积分, 专业版={}CNY/{}积分", standardPrice, standardPoints, proPrice, proPoints);
|
||||
|
||||
// 根据金额计算积分
|
||||
int points = 0;
|
||||
String planName = "";
|
||||
int amountInt = amount.intValue();
|
||||
|
||||
// 专业版订阅 (259元以上) -> 1000积分
|
||||
if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
|
||||
points = 1000;
|
||||
// 判断套餐类型:先检查专业版(价格更高),再检查标准版
|
||||
// 允许10%的价格浮动范围
|
||||
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
|
||||
points = proPoints; // 专业版积分
|
||||
planName = "专业版";
|
||||
}
|
||||
// 标准版订阅 (59-258元) -> 200积分
|
||||
else if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0) {
|
||||
points = 200;
|
||||
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
|
||||
points = standardPoints; // 标准版积分
|
||||
planName = "标准版";
|
||||
}
|
||||
// 其他金额不增加积分
|
||||
else {
|
||||
logger.info("支付金额不在套餐范围内,不增加积分: amount={}", amount);
|
||||
} else if (amountInt >= proPrice) {
|
||||
// 如果金额大于等于专业版价格,按专业版计算
|
||||
points = proPoints;
|
||||
planName = "专业版";
|
||||
} else if (amountInt >= standardPrice) {
|
||||
// 如果金额大于等于标准版价格,按标准版计算
|
||||
points = standardPoints;
|
||||
planName = "标准版";
|
||||
} else {
|
||||
logger.info("支付金额不在套餐范围内,不增加积分: amount={}, 标准版价格={}, 专业版价格={}", amountInt, standardPrice, proPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
// 增加积分
|
||||
Long userId = payment.getUser().getId();
|
||||
logger.info("开始为用户增加积分: userId={}, points={}, plan={}, paymentId={}", userId, points, planName, payment.getId());
|
||||
logger.info("开始为用户增加积分: userId={}, points={}, plan={}, paymentId={}, amount={}", userId, points, planName, payment.getId(), amountInt);
|
||||
|
||||
userService.addPoints(userId, points);
|
||||
|
||||
@@ -222,14 +225,42 @@ public class PaymentService {
|
||||
User user = null;
|
||||
if (username != null) { try { user = userService.findByUsername(username); } catch (Exception e) {} }
|
||||
if (user == null) { user = userService.findByUsernameOrNull(username != null ? username : "anon"); if (user == null) user = createAnonymousUser(username != null ? username : "anon"); }
|
||||
|
||||
BigDecimal amount = new BigDecimal(amountStr);
|
||||
|
||||
// 先创建待支付订单
|
||||
Order order = new Order();
|
||||
order.setUser(user);
|
||||
order.setOrderNumber("ORD" + System.currentTimeMillis());
|
||||
order.setTotalAmount(amount);
|
||||
order.setCurrency("CNY");
|
||||
order.setStatus(OrderStatus.PENDING); // 待支付状态
|
||||
order.setOrderType(OrderType.SUBSCRIPTION);
|
||||
|
||||
// 根据金额设置订单描述
|
||||
if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
|
||||
order.setDescription("专业版会员订阅 - " + amount + "元");
|
||||
} else if (amount.compareTo(new BigDecimal("59.00")) >= 0) {
|
||||
order.setDescription("标准版会员订阅 - " + amount + "元");
|
||||
} else {
|
||||
order.setDescription("会员订阅 - " + amount + "元");
|
||||
}
|
||||
|
||||
Order savedOrder = orderService.createOrder(order);
|
||||
logger.info("创建待支付订单: orderId={}, orderNumber={}, amount={}", savedOrder.getId(), savedOrder.getOrderNumber(), amount);
|
||||
|
||||
// 创建支付记录并关联订单
|
||||
Payment payment = new Payment();
|
||||
payment.setUser(user);
|
||||
payment.setOrderId(orderId);
|
||||
payment.setAmount(new BigDecimal(amountStr));
|
||||
payment.setAmount(amount);
|
||||
payment.setCurrency("CNY");
|
||||
payment.setPaymentMethod(PaymentMethod.valueOf(method));
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
payment.setCreatedAt(LocalDateTime.now());
|
||||
payment.setOrder(savedOrder); // 关联订单
|
||||
payment.setDescription(order.getDescription());
|
||||
|
||||
return paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.example.demo.config.DynamicApiConfig;
|
||||
@@ -35,20 +34,6 @@ public class RealAIService {
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
// 保留@Value作为后备,但优先使用DynamicApiConfig
|
||||
@Value("${ai.api.base-url:https://ai.comfly.chat}")
|
||||
private String fallbackApiBaseUrl;
|
||||
|
||||
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String fallbackApiKey;
|
||||
|
||||
@Value("${ai.image.api.base-url:https://ai.comfly.chat}")
|
||||
private String fallbackImageApiBaseUrl;
|
||||
|
||||
@Value("${ai.image.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String fallbackImageApiKey;
|
||||
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RealAIService() {
|
||||
@@ -61,55 +46,31 @@ public class RealAIService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的API密钥(优先使用动态配置)
|
||||
* 获取当前有效的API密钥(使用动态配置)
|
||||
*/
|
||||
private String getEffectiveApiKey() {
|
||||
if (dynamicApiConfig != null) {
|
||||
String key = dynamicApiConfig.getApiKey();
|
||||
if (key != null && !key.isEmpty()) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return fallbackApiKey;
|
||||
return dynamicApiConfig.getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的API基础URL(优先使用动态配置)
|
||||
* 获取当前有效的API基础URL(使用动态配置)
|
||||
*/
|
||||
private String getEffectiveApiBaseUrl() {
|
||||
if (dynamicApiConfig != null) {
|
||||
String url = dynamicApiConfig.getApiBaseUrl();
|
||||
if (url != null && !url.isEmpty()) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return fallbackApiBaseUrl;
|
||||
return dynamicApiConfig.getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的图片API密钥(优先使用动态配置)
|
||||
* 获取当前有效的图片API密钥(使用动态配置)
|
||||
*/
|
||||
private String getEffectiveImageApiKey() {
|
||||
if (dynamicApiConfig != null) {
|
||||
String key = dynamicApiConfig.getImageApiKey();
|
||||
if (key != null && !key.isEmpty()) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return fallbackImageApiKey;
|
||||
return dynamicApiConfig.getImageApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的图片API基础URL(优先使用动态配置)
|
||||
* 获取当前有效的图片API基础URL(使用动态配置)
|
||||
*/
|
||||
private String getEffectiveImageApiBaseUrl() {
|
||||
if (dynamicApiConfig != null) {
|
||||
String url = dynamicApiConfig.getImageApiBaseUrl();
|
||||
if (url != null && !url.isEmpty()) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return fallbackImageApiBaseUrl;
|
||||
return dynamicApiConfig.getImageApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +100,7 @@ public class RealAIService {
|
||||
requestMap.put("prompt", prompt);
|
||||
requestMap.put("model", modelName);
|
||||
requestMap.put("images", validatedImages); // 使用images数组(参考sora2实现)
|
||||
requestMap.put("size", convertAspectRatioToSize(aspectRatio, hdMode));
|
||||
requestMap.put("aspect_ratio", aspectRatio);
|
||||
requestMap.put("duration", duration);
|
||||
requestMap.put("hd", hdMode);
|
||||
@@ -276,6 +238,7 @@ public class RealAIService {
|
||||
requestMap.put("prompt", prompt);
|
||||
requestMap.put("model", modelName);
|
||||
requestMap.put("images", imagesList); // 使用 images 数组,不是单个 image
|
||||
requestMap.put("size", size);
|
||||
requestMap.put("aspect_ratio", aspectRatio);
|
||||
requestMap.put("duration", duration);
|
||||
requestMap.put("hd", hdMode);
|
||||
@@ -441,7 +404,7 @@ public class RealAIService {
|
||||
// 提交文生视频任务(参考Comfly_sora2实现)
|
||||
// 使用 /v2/videos/generations 端点,JSON格式
|
||||
|
||||
String url = fallbackApiBaseUrl + "/v2/videos/generations";
|
||||
String url = getEffectiveApiBaseUrl() + "/v2/videos/generations";
|
||||
|
||||
// 构建请求体(参考Comfly项目Comfly_sora2类的格式)
|
||||
Map<String, Object> requestBodyMap = new HashMap<>();
|
||||
@@ -458,7 +421,7 @@ public class RealAIService {
|
||||
|
||||
// 使用 JSON 格式(参考Comfly_sora2实现)
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + fallbackApiKey)
|
||||
.header("Authorization", "Bearer " + getEffectiveApiKey())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(requestBody)
|
||||
.asString();
|
||||
@@ -1220,7 +1183,7 @@ public class RealAIService {
|
||||
|
||||
// 设置超时时间(60秒)
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + fallbackApiKey)
|
||||
.header("Authorization", "Bearer " + getEffectiveApiKey())
|
||||
.header("Content-Type", "application/json")
|
||||
.socketTimeout(60000)
|
||||
.connectTimeout(15000)
|
||||
@@ -1399,7 +1362,7 @@ public class RealAIService {
|
||||
|
||||
// 多模态需要更长的超时时间(120秒)
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + fallbackApiKey)
|
||||
.header("Authorization", "Bearer " + getEffectiveApiKey())
|
||||
.header("Content-Type", "application/json")
|
||||
.socketTimeout(120000)
|
||||
.connectTimeout(30000)
|
||||
|
||||
@@ -111,6 +111,17 @@ public class StoryboardVideoService {
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
// 冻结分镜图生成积分(30积分)
|
||||
try {
|
||||
userService.freezePoints(username, taskId + "_img",
|
||||
com.example.demo.model.PointsFreezeRecord.TaskType.STORYBOARD_IMAGE,
|
||||
30, "分镜图生成");
|
||||
logger.info("分镜图积分冻结成功: taskId={}, username={}, points=30", taskId, username);
|
||||
} catch (Exception e) {
|
||||
logger.error("分镜图积分冻结失败: taskId={}, username={}", taskId, username, e);
|
||||
throw new RuntimeException("积分不足,无法创建任务: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt.trim(), aspectRatio, hdMode, duration);
|
||||
task.setTaskId(taskId);
|
||||
@@ -159,6 +170,14 @@ public class StoryboardVideoService {
|
||||
|
||||
logger.info("分镜视频任务创建成功: {}, 用户: {}", taskId, username);
|
||||
|
||||
// 创建PROCESSING状态的UserWork,以便用户刷新页面后能恢复任务,同时在"我的作品"中显示
|
||||
try {
|
||||
userWorkService.createProcessingStoryboardVideoWork(task);
|
||||
logger.info("创建PROCESSING状态分镜视频作品成功: {}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("创建PROCESSING状态作品失败(不影响任务执行): {}", taskId, e);
|
||||
}
|
||||
|
||||
// 注意:异步方法调用必须在事务提交后执行,避免占用连接
|
||||
// 使用 TransactionSynchronizationManager 确保在事务提交后再调用异步方法
|
||||
// 通过 ApplicationContext 获取代理对象,确保 @Async 生效
|
||||
@@ -619,6 +638,14 @@ public class StoryboardVideoService {
|
||||
taskRepository.save(task);
|
||||
logger.info("分镜图生成完成,任务状态已更新为 COMPLETED: taskId={}", taskId);
|
||||
|
||||
// 扣除分镜图冻结的积分
|
||||
try {
|
||||
userService.deductFrozenPoints(taskId + "_img");
|
||||
logger.info("分镜图积分扣除成功: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("分镜图积分扣除失败(不影响任务完成): taskId={}", taskId, e);
|
||||
}
|
||||
|
||||
// 更新 TaskStatus 为 COMPLETED(分镜图生成完成,停止轮询)
|
||||
// 用户点击"生成视频"时,会重新将状态改为 PROCESSING 并添加 externalTaskId
|
||||
try {
|
||||
@@ -667,6 +694,14 @@ public class StoryboardVideoService {
|
||||
*/
|
||||
private void updateTaskStatusToFailedWithTransactionTemplate(String taskId, String errorMessage) {
|
||||
try {
|
||||
// 返还分镜图冻结的积分
|
||||
try {
|
||||
userService.returnFrozenPoints(taskId + "_img");
|
||||
logger.info("分镜图积分返还成功: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("分镜图积分返还失败(可能积分记录不存在): taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
|
||||
// 通过更新 task_status 触发级联更新(自动同步到业务表和 user_works)
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||
if (updated) {
|
||||
@@ -815,6 +850,17 @@ public class StoryboardVideoService {
|
||||
throw new RuntimeException("分镜图尚未生成,无法生成视频");
|
||||
}
|
||||
|
||||
// 冻结分镜视频生成积分(30积分)
|
||||
try {
|
||||
userService.freezePoints(task.getUsername(), taskId + "_vid",
|
||||
com.example.demo.model.PointsFreezeRecord.TaskType.STORYBOARD_VIDEO,
|
||||
30, "分镜视频生成");
|
||||
logger.info("分镜视频积分冻结成功: taskId={}, username={}, points=30", taskId, task.getUsername());
|
||||
} catch (Exception e) {
|
||||
logger.error("分镜视频积分冻结失败: taskId={}, username={}", taskId, task.getUsername(), e);
|
||||
throw new RuntimeException("积分不足,无法生成视频: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 检查任务状态:允许从 PROCESSING、COMPLETED 或 FAILED(重试)状态生成视频
|
||||
// 只要分镜图已生成,就允许重试生成视频
|
||||
if (task.getStatus() != StoryboardVideoTask.TaskStatus.PROCESSING &&
|
||||
|
||||
@@ -51,22 +51,24 @@ public class SystemSettingsService {
|
||||
|
||||
@Transactional
|
||||
public SystemSettings update(SystemSettings updated) {
|
||||
// 确保只有一条记录
|
||||
SystemSettings current = getOrCreate();
|
||||
current.setStandardPriceCny(updated.getStandardPriceCny());
|
||||
current.setProPriceCny(updated.getProPriceCny());
|
||||
current.setPointsPerGeneration(updated.getPointsPerGeneration());
|
||||
current.setSiteName(updated.getSiteName());
|
||||
current.setSiteSubtitle(updated.getSiteSubtitle());
|
||||
current.setRegistrationOpen(updated.getRegistrationOpen());
|
||||
current.setMaintenanceMode(updated.getMaintenanceMode());
|
||||
current.setEnableAlipay(updated.getEnableAlipay());
|
||||
current.setEnablePaypal(updated.getEnablePaypal());
|
||||
current.setContactEmail(updated.getContactEmail());
|
||||
current.setPromptOptimizationModel(updated.getPromptOptimizationModel());
|
||||
current.setPromptOptimizationApiUrl(updated.getPromptOptimizationApiUrl());
|
||||
current.setStoryboardSystemPrompt(updated.getStoryboardSystemPrompt());
|
||||
return repository.save(current);
|
||||
logger.info("要更新的值: standardPriceCny={}, proPriceCny={}",
|
||||
updated.getStandardPriceCny(), updated.getProPriceCny());
|
||||
|
||||
// 使用原生更新方法强制更新数据库
|
||||
Long id = updated.getId();
|
||||
if (id == null) {
|
||||
id = 1L; // 默认ID
|
||||
}
|
||||
|
||||
int rows1 = repository.updateStandardPrice(id, updated.getStandardPriceCny());
|
||||
int rows2 = repository.updateProPrice(id, updated.getProPriceCny());
|
||||
|
||||
logger.info("更新标准价格影响行数: {}, 更新专业价格影响行数: {}", rows1, rows2);
|
||||
|
||||
// 重新查询返回最新数据
|
||||
SystemSettings saved = getOrCreate();
|
||||
logger.info("系统设置保存成功: id={}, standardPriceCny={}", saved.getId(), saved.getStandardPriceCny());
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,9 @@ public class TaskQueueService {
|
||||
@Autowired
|
||||
private TaskStatusPollingService taskStatusPollingService;
|
||||
|
||||
@Autowired
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${app.temp.dir:./temp}")
|
||||
private String tempDir;
|
||||
|
||||
@@ -388,6 +391,19 @@ public class TaskQueueService {
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
textToVideoTaskRepository.save(task);
|
||||
// 回退时记录错误日志
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
task.getUsername(),
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_TIMEOUT,
|
||||
"任务超时(创建超过1小时)",
|
||||
"TaskQueueService.recoverOrCleanupStuckTasks",
|
||||
task.getTaskId(),
|
||||
"TEXT_TO_VIDEO"
|
||||
);
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录错误日志失败: {}", task.getTaskId());
|
||||
}
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:文生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
@@ -413,6 +429,19 @@ public class TaskQueueService {
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
imageToVideoTaskRepository.save(task);
|
||||
// 回退时记录错误日志
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
task.getUsername(),
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_TIMEOUT,
|
||||
"任务超时(创建超过1小时)",
|
||||
"TaskQueueService.recoverOrCleanupStuckTasks",
|
||||
task.getTaskId(),
|
||||
"IMAGE_TO_VIDEO"
|
||||
);
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录错误日志失败: {}", task.getTaskId());
|
||||
}
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:图生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
@@ -440,6 +469,19 @@ public class TaskQueueService {
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
// 回退时记录错误日志
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
task.getUsername(),
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_TIMEOUT,
|
||||
"任务超时(创建超过1小时)",
|
||||
"TaskQueueService.recoverOrCleanupStuckTasks",
|
||||
task.getTaskId(),
|
||||
"STORYBOARD_VIDEO"
|
||||
);
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录错误日志失败: {}", task.getTaskId());
|
||||
}
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
@@ -452,6 +494,19 @@ public class TaskQueueService {
|
||||
task.setErrorMessage("系统重启,分镜图生成任务已取消");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
// 回退时记录错误日志
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
task.getUsername(),
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
"系统重启,分镜图生成任务已取消",
|
||||
"TaskQueueService.recoverOrCleanupStuckTasks",
|
||||
task.getTaskId(),
|
||||
"STORYBOARD_VIDEO"
|
||||
);
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录错误日志失败: {}", task.getTaskId());
|
||||
}
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:分镜视频任务 {} 无外部任务ID,标记为失败", task.getTaskId());
|
||||
@@ -621,16 +676,18 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算任务所需积分 - 降低积分要求
|
||||
* 计算任务所需积分 - 统一30积分
|
||||
*/
|
||||
private Integer calculateRequiredPoints(TaskQueue.TaskType taskType) {
|
||||
switch (taskType) {
|
||||
case TEXT_TO_VIDEO:
|
||||
return 20; // 文生视频默认20积分
|
||||
return 30; // 文生视频30积分
|
||||
case IMAGE_TO_VIDEO:
|
||||
return 25; // 图生视频默认25积分
|
||||
return 30; // 图生视频30积分
|
||||
case STORYBOARD_VIDEO:
|
||||
return 30; // 分镜视频默认30积分(包含图片生成和视频生成)
|
||||
return 30; // 分镜视频30积分
|
||||
case STORYBOARD_IMAGE:
|
||||
return 30; // 分镜图30积分
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的任务类型: " + taskType);
|
||||
}
|
||||
@@ -647,6 +704,8 @@ public class TaskQueueService {
|
||||
return PointsFreezeRecord.TaskType.IMAGE_TO_VIDEO;
|
||||
case STORYBOARD_VIDEO:
|
||||
return PointsFreezeRecord.TaskType.STORYBOARD_VIDEO;
|
||||
case STORYBOARD_IMAGE:
|
||||
return PointsFreezeRecord.TaskType.STORYBOARD_IMAGE;
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的任务类型: " + taskType);
|
||||
}
|
||||
@@ -663,6 +722,8 @@ public class TaskQueueService {
|
||||
return TaskStatus.TaskType.IMAGE_TO_VIDEO;
|
||||
case STORYBOARD_VIDEO:
|
||||
return TaskStatus.TaskType.STORYBOARD_VIDEO;
|
||||
case STORYBOARD_IMAGE:
|
||||
return TaskStatus.TaskType.STORYBOARD_IMAGE;
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的任务类型: " + taskType);
|
||||
}
|
||||
@@ -728,10 +789,15 @@ public class TaskQueueService {
|
||||
|
||||
// 返还冻结的积分
|
||||
try {
|
||||
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
|
||||
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||
userService.returnFrozenPoints(taskId + "_vid");
|
||||
} else {
|
||||
userService.returnFrozenPoints(taskId);
|
||||
}
|
||||
logger.info("已返还冻结积分: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("返还冻结积分失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
logger.warn("返还冻结积分失败(可能积分记录不存在): taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
|
||||
// 删除队列记录(失败任务不再保留在队列中)
|
||||
@@ -1043,12 +1109,17 @@ public class TaskQueueService {
|
||||
String videoPromptForApi = videoPromptBuilder.length() > 0 ? videoPromptBuilder.toString() : task.getPrompt();
|
||||
|
||||
// 直接使用网格图调用图生视频接口
|
||||
String gridAspectRatio = task.getAspectRatio();
|
||||
String gridDuration = task.getDuration() != null ? task.getDuration().toString() : "10";
|
||||
boolean gridHdMode = task.isHdMode();
|
||||
logger.info("提交视频生成请求(网格图): aspectRatio={}, duration={}, hdMode={}",
|
||||
gridAspectRatio, gridDuration, gridHdMode);
|
||||
Map<String, Object> result = realAIService.submitImageToVideoTask(
|
||||
videoPromptForApi,
|
||||
imageBase64,
|
||||
task.getAspectRatio(),
|
||||
"10", // 默认10秒
|
||||
task.isHdMode()
|
||||
gridAspectRatio,
|
||||
gridDuration,
|
||||
gridHdMode
|
||||
);
|
||||
|
||||
if (result != null && result.containsKey("task_id")) {
|
||||
@@ -1133,12 +1204,16 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
// 使用拼好的图片调用图生视频接口
|
||||
logger.info("使用拼好的图片调用图生视频接口,提示词长度: {}...", videoPromptForApi.length());
|
||||
String finalAspectRatio = task.getAspectRatio();
|
||||
String finalDuration = task.getDuration() != null ? task.getDuration().toString() : "10";
|
||||
boolean finalHdMode = task.isHdMode();
|
||||
logger.info("提交视频生成请求: aspectRatio={}, duration={}, hdMode={}, 提示词长度={}",
|
||||
finalAspectRatio, finalDuration, finalHdMode, videoPromptForApi.length());
|
||||
Map<String, Object> result = realAIService.submitImageToVideoTask(
|
||||
videoPromptForApi,
|
||||
imageForVideo,
|
||||
task.getAspectRatio(),
|
||||
"10", // 默认10秒
|
||||
task.getDuration() != null ? task.getDuration().toString() : "10",
|
||||
task.isHdMode()
|
||||
);
|
||||
|
||||
@@ -1820,9 +1895,15 @@ public class TaskQueueService {
|
||||
|
||||
// 扣除冻结的积分(内部已处理重复扣除的情况)
|
||||
try {
|
||||
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
|
||||
if (freshTaskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||
userService.deductFrozenPoints(taskId + "_vid");
|
||||
} else {
|
||||
userService.deductFrozenPoints(taskId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 积分扣除失败不影响任务完成状态
|
||||
logger.warn("积分扣除失败(不影响任务完成): taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
|
||||
// 更新原始任务状态(先用原始URL)
|
||||
@@ -2092,6 +2173,40 @@ public class TaskQueueService {
|
||||
} catch (Exception e) {
|
||||
logger.warn("回退更新 UserWork 状态失败: {}", taskId, e);
|
||||
}
|
||||
|
||||
// 记录错误日志(从业务表获取用户名)
|
||||
try {
|
||||
String username = null;
|
||||
String taskType = "UNKNOWN";
|
||||
|
||||
if (taskId.startsWith("img2vid_")) {
|
||||
taskType = "IMAGE_TO_VIDEO";
|
||||
username = imageToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("storyboard_")) {
|
||||
taskType = "STORYBOARD_VIDEO";
|
||||
username = storyboardVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
taskType = "TEXT_TO_VIDEO";
|
||||
username = textToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
}
|
||||
|
||||
if (username != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
username,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
errorMessage,
|
||||
"TaskQueueService.fallbackUpdateBusinessTaskFailed",
|
||||
taskId,
|
||||
taskType
|
||||
);
|
||||
logger.info("已记录错误日志: taskId={}, username={}", taskId, username);
|
||||
}
|
||||
} catch (Exception logException) {
|
||||
logger.warn("记录错误日志失败: {}", taskId, logException);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2109,11 +2224,34 @@ public class TaskQueueService {
|
||||
taskQueueRepository.save(taskQueue);
|
||||
|
||||
// 返还冻结的积分
|
||||
try {
|
||||
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
|
||||
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
|
||||
} else {
|
||||
userService.returnFrozenPoints(taskQueue.getTaskId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("积分返还失败(可能积分记录不存在): taskId={}, error={}", taskQueue.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
// 更新原始任务状态
|
||||
updateOriginalTaskStatus(taskQueue, "FAILED", null, errorMessage);
|
||||
|
||||
// 记录错误日志
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
taskQueue.getUsername(),
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
errorMessage,
|
||||
"TaskQueueService",
|
||||
taskQueue.getTaskId(),
|
||||
taskQueue.getTaskType() != null ? taskQueue.getTaskType().name() : "UNKNOWN"
|
||||
);
|
||||
} catch (Exception logException) {
|
||||
logger.warn("记录错误日志失败: {}", taskQueue.getTaskId(), logException);
|
||||
}
|
||||
|
||||
// 同步更新用户作品状态为 FAILED,避免前端将失败任务当作进行中任务恢复
|
||||
try {
|
||||
userWorkService.updateWorkStatus(taskQueue.getTaskId(), UserWork.WorkStatus.FAILED);
|
||||
@@ -2173,6 +2311,20 @@ public class TaskQueueService {
|
||||
// 更新原始任务状态
|
||||
updateOriginalTaskStatus(taskQueue, "FAILED", null, "任务处理超时");
|
||||
|
||||
// 记录超时错误日志
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
taskQueue.getUsername(),
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_TIMEOUT,
|
||||
"任务处理超时",
|
||||
"TaskQueueService",
|
||||
taskQueue.getTaskId(),
|
||||
taskQueue.getTaskType() != null ? taskQueue.getTaskType().name() : "UNKNOWN"
|
||||
);
|
||||
} catch (Exception logException) {
|
||||
logger.warn("记录超时错误日志失败: {}", taskQueue.getTaskId(), logException);
|
||||
}
|
||||
|
||||
// 超时任务同样标记为 FAILED,防止前端一直恢复到创作页面
|
||||
try {
|
||||
userWorkService.updateWorkStatus(taskQueue.getTaskId(), UserWork.WorkStatus.FAILED);
|
||||
@@ -2543,6 +2695,15 @@ public class TaskQueueService {
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
});
|
||||
break;
|
||||
case STORYBOARD_IMAGE:
|
||||
// 分镜图任务使用 StoryboardVideoTask 表存储
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 回退时也需要更新 UserWork
|
||||
@@ -2552,6 +2713,42 @@ public class TaskQueueService {
|
||||
} catch (Exception workException) {
|
||||
logger.warn("回退更新UserWork状态失败: taskId={}, error={}", taskId, workException.getMessage());
|
||||
}
|
||||
|
||||
// 回退时记录错误日志
|
||||
try {
|
||||
String username = null;
|
||||
String taskTypeStr = taskType != null ? taskType.name() : "UNKNOWN";
|
||||
|
||||
switch (taskType) {
|
||||
case TEXT_TO_VIDEO:
|
||||
username = textToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
break;
|
||||
case IMAGE_TO_VIDEO:
|
||||
username = imageToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
break;
|
||||
case STORYBOARD_VIDEO:
|
||||
case STORYBOARD_IMAGE:
|
||||
username = storyboardVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
break;
|
||||
}
|
||||
|
||||
if (username != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
username,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
errorMessage,
|
||||
"TaskQueueService.updateRelatedTaskStatusWithError",
|
||||
taskId,
|
||||
taskTypeStr
|
||||
);
|
||||
logger.info("回退时已记录错误日志: taskId={}, username={}", taskId, username);
|
||||
}
|
||||
} catch (Exception logException) {
|
||||
logger.warn("回退时记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新关联任务状态失败: taskId={}, taskType={}", taskId, taskType, e);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -30,6 +32,9 @@ public class TaskStatusPollingService {
|
||||
// 任务超时时间(小时)
|
||||
private static final int TASK_TIMEOUT_HOURS = 1;
|
||||
|
||||
// 记录已经记录过失败日志的任务ID,避免重复记录
|
||||
private final Set<String> loggedFailedTaskIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Autowired
|
||||
private TaskStatusRepository taskStatusRepository;
|
||||
|
||||
@@ -46,6 +51,15 @@ public class TaskStatusPollingService {
|
||||
@Autowired
|
||||
private com.example.demo.repository.StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.repository.ImageToVideoTaskRepository imageToVideoTaskRepository;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.repository.TextToVideoTaskRepository textToVideoTaskRepository;
|
||||
|
||||
@Autowired
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String apiKey;
|
||||
|
||||
@@ -103,6 +117,8 @@ public class TaskStatusPollingService {
|
||||
logger.warn("任务 {} 无外部任务ID且已超时,标记为失败", task.getTaskId());
|
||||
task.markAsFailed("任务超时:未获取到外部任务ID");
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等,并记录错误日志
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:未获取到外部任务ID");
|
||||
} else {
|
||||
logger.info("任务 {} 无外部任务ID但未超时,保持现状", task.getTaskId());
|
||||
}
|
||||
@@ -131,18 +147,24 @@ public class TaskStatusPollingService {
|
||||
logger.info("任务 {} 恢复为已完成状态,resultUrl={}", task.getTaskId(), resultUrl);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
|
||||
} else if ("FAILED".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
|
||||
} else if ("FAILED".equalsIgnoreCase(status) || "FAILURE".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
|
||||
// 任务失败
|
||||
String failReason = responseJson.path("fail_reason").asText("外部API返回失败");
|
||||
task.markAsFailed(failReason);
|
||||
taskStatusRepository.save(task);
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 恢复为失败状态,原因: {}", task.getTaskId(), failReason);
|
||||
}
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), failReason);
|
||||
} else {
|
||||
// 任务仍在处理中,检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
}
|
||||
task.markAsFailed("任务超时");
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
@@ -254,7 +276,10 @@ public class TaskStatusPollingService {
|
||||
boolean isVideoTask = taskId != null && (taskId.startsWith("txt2vid_") || taskId.startsWith("img2vid_"));
|
||||
|
||||
if (isVideoTask) {
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(taskId)) {
|
||||
logger.warn("视频任务 {} 的 externalTaskId 为空,标记为失败并返还积分", taskId);
|
||||
}
|
||||
String errorMessage = "任务提交失败:未能成功提交到外部API,请检查网络或稍后重试";
|
||||
markTaskFailed(taskId, errorMessage);
|
||||
|
||||
@@ -291,12 +316,26 @@ public class TaskStatusPollingService {
|
||||
task.getTaskId(), response.getStatus(), response.getBody());
|
||||
// 更新轮询次数(使用单独的事务方法)
|
||||
incrementPollCountWithTransaction(task);
|
||||
|
||||
// 检查是否超时,超时则标记失败
|
||||
if (isTaskTimeout(task)) {
|
||||
String errorMessage = "任务超时:外部API返回错误状态码 " + response.getStatus();
|
||||
markTaskFailed(task.getTaskId(), errorMessage);
|
||||
pendingAction = new String[]{"FAILED", task.getTaskId(), errorMessage};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("轮询任务状态异常: taskId={}, error={}", task.getTaskId(), e.getMessage(), e);
|
||||
// 更新轮询次数(使用单独的事务方法)
|
||||
incrementPollCountWithTransaction(task);
|
||||
|
||||
// 检查是否超时,超时则标记失败
|
||||
if (isTaskTimeout(task)) {
|
||||
String errorMessage = "任务超时:轮询异常 - " + e.getMessage();
|
||||
markTaskFailed(task.getTaskId(), errorMessage);
|
||||
pendingAction = new String[]{"FAILED", task.getTaskId(), errorMessage};
|
||||
}
|
||||
}
|
||||
|
||||
// 在事务外处理后续操作(避免事务超时)
|
||||
@@ -367,9 +406,13 @@ public class TaskStatusPollingService {
|
||||
return null;
|
||||
|
||||
case "failed":
|
||||
case "failure":
|
||||
case "error":
|
||||
task.markAsFailed(errorMessage);
|
||||
// 只在第一次记录失败日志,避免重复记录
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务失败: taskId={}, error={}", task.getTaskId(), errorMessage);
|
||||
}
|
||||
taskStatusRepository.save(task);
|
||||
// 返回任务信息,在事务外处理
|
||||
return new String[]{"FAILED", task.getTaskId(), errorMessage};
|
||||
@@ -377,12 +420,21 @@ public class TaskStatusPollingService {
|
||||
case "processing":
|
||||
case "in_progress":
|
||||
case "not_start":
|
||||
case "pending":
|
||||
case "queued":
|
||||
task.setStatus(TaskStatus.Status.PROCESSING);
|
||||
logger.info("任务处理中: taskId={}, progress={}%", task.getTaskId(), progress);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn("未知任务状态: taskId={}, status={}", task.getTaskId(), status);
|
||||
// 未知状态也检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
String timeoutError = "任务超时:外部API返回未知状态 " + status;
|
||||
task.markAsFailed(timeoutError);
|
||||
taskStatusRepository.save(task);
|
||||
return new String[]{"FAILED", task.getTaskId(), timeoutError};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -406,6 +458,13 @@ public class TaskStatusPollingService {
|
||||
task.markAsTimeout();
|
||||
taskStatusRepository.save(task);
|
||||
logger.warn("任务超时: taskId={}, pollCount={}", task.getTaskId(), task.getPollCount());
|
||||
|
||||
// 同步更新业务表、UserWork 等,并记录错误日志
|
||||
try {
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时(轮询次数已达上限)");
|
||||
} catch (Exception e) {
|
||||
logger.error("处理超时任务失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!timeoutTasks.isEmpty()) {
|
||||
@@ -531,8 +590,10 @@ public class TaskStatusPollingService {
|
||||
taskStatusRepository.save(taskStatus);
|
||||
logger.info("任务状态已更新: taskId={}, status={}", taskId, status);
|
||||
|
||||
// 手动同步 StoryboardVideoTask 状态(避免依赖数据库触发器)
|
||||
if (taskId != null && taskId.startsWith("sb_")) {
|
||||
// 手动同步业务表状态(避免依赖数据库触发器)
|
||||
if (taskId != null) {
|
||||
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
|
||||
// 同步分镜视频任务
|
||||
try {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.StoryboardVideoTask.TaskStatus newTaskStatus =
|
||||
@@ -547,18 +608,113 @@ public class TaskStatusPollingService {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100); // 完成时设置进度为100%
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}, progress={}", taskId, newTaskStatus, task.getProgress());
|
||||
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 StoryboardVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
} else if (taskId.startsWith("img2vid_")) {
|
||||
// 同步图生视频任务
|
||||
try {
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.ImageToVideoTask.TaskStatus newTaskStatus =
|
||||
convertToImageToVideoTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.updateStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("已同步 ImageToVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 ImageToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
// 同步文生视频任务
|
||||
try {
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.TextToVideoTask.TaskStatus newTaskStatus =
|
||||
convertToTextToVideoTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.updateStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
textToVideoTaskRepository.save(task);
|
||||
logger.info("已同步 TextToVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 TextToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是失败状态,记录错误日志
|
||||
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
|
||||
try {
|
||||
String username = null;
|
||||
String taskType = "UNKNOWN";
|
||||
|
||||
if (taskId.startsWith("img2vid_")) {
|
||||
taskType = "IMAGE_TO_VIDEO";
|
||||
username = imageToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
|
||||
taskType = "STORYBOARD_VIDEO";
|
||||
username = storyboardVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
taskType = "TEXT_TO_VIDEO";
|
||||
username = textToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
}
|
||||
|
||||
if (username != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
username,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
errorMessage,
|
||||
"TaskStatusPollingService.updateTaskStatusWithCascade",
|
||||
taskId,
|
||||
taskType
|
||||
);
|
||||
logger.info("已记录错误日志: taskId={}, username={}, errorMessage={}", taskId, username, errorMessage);
|
||||
} else {
|
||||
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
|
||||
}
|
||||
} catch (Exception logException) {
|
||||
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -577,6 +733,32 @@ public class TaskStatusPollingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 TaskStatus.Status 转换为 ImageToVideoTask.TaskStatus
|
||||
*/
|
||||
private com.example.demo.model.ImageToVideoTask.TaskStatus convertToImageToVideoTaskStatus(TaskStatus.Status status) {
|
||||
return switch (status) {
|
||||
case PENDING -> com.example.demo.model.ImageToVideoTask.TaskStatus.PENDING;
|
||||
case PROCESSING -> com.example.demo.model.ImageToVideoTask.TaskStatus.PROCESSING;
|
||||
case COMPLETED -> com.example.demo.model.ImageToVideoTask.TaskStatus.COMPLETED;
|
||||
case FAILED -> com.example.demo.model.ImageToVideoTask.TaskStatus.FAILED;
|
||||
default -> com.example.demo.model.ImageToVideoTask.TaskStatus.PROCESSING;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 TaskStatus.Status 转换为 TextToVideoTask.TaskStatus
|
||||
*/
|
||||
private com.example.demo.model.TextToVideoTask.TaskStatus convertToTextToVideoTaskStatus(TaskStatus.Status status) {
|
||||
return switch (status) {
|
||||
case PENDING -> com.example.demo.model.TextToVideoTask.TaskStatus.PENDING;
|
||||
case PROCESSING -> com.example.demo.model.TextToVideoTask.TaskStatus.PROCESSING;
|
||||
case COMPLETED -> com.example.demo.model.TextToVideoTask.TaskStatus.COMPLETED;
|
||||
case FAILED -> com.example.demo.model.TextToVideoTask.TaskStatus.FAILED;
|
||||
default -> com.example.demo.model.TextToVideoTask.TaskStatus.PROCESSING;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记任务为完成状态(触发级联)
|
||||
*/
|
||||
|
||||
@@ -566,4 +566,69 @@ public class TextToVideoService {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试失败的文生视频任务
|
||||
* 复用原task_id,重新提交至外部API
|
||||
*/
|
||||
@Transactional
|
||||
public TextToVideoTask retryTask(String taskId, String username) {
|
||||
logger.info("重试失败任务: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 获取任务
|
||||
TextToVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));
|
||||
|
||||
// 验证权限
|
||||
if (!task.getUsername().equals(username)) {
|
||||
throw new RuntimeException("无权操作此任务");
|
||||
}
|
||||
|
||||
// 验证任务状态必须是 FAILED
|
||||
if (task.getStatus() != TextToVideoTask.TaskStatus.FAILED) {
|
||||
throw new RuntimeException("只能重试失败的任务,当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PENDING);
|
||||
task.setErrorMessage(null);
|
||||
task.setRealTaskId(null); // 清除旧的外部任务ID
|
||||
task.setProgress(0);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
|
||||
// 重新添加到任务队列
|
||||
try {
|
||||
taskQueueService.addTextToVideoTask(username, taskId);
|
||||
logger.info("重试任务已添加到队列: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("添加重试任务到队列失败: taskId={}", taskId, e);
|
||||
// 回滚任务状态
|
||||
task.setStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("重试失败: " + e.getMessage());
|
||||
taskRepository.save(task);
|
||||
throw new RuntimeException("重试失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 更新UserWork状态为PROCESSING
|
||||
try {
|
||||
userWorkService.updateWorkStatus(taskId, com.example.demo.model.UserWork.WorkStatus.PROCESSING);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}", taskId, e);
|
||||
}
|
||||
|
||||
// 更新task_status表状态
|
||||
try {
|
||||
taskStatusPollingService.updateTaskStatusWithCascade(
|
||||
taskId,
|
||||
com.example.demo.model.TaskStatus.Status.PROCESSING,
|
||||
null,
|
||||
null
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新task_status状态失败: taskId={}", taskId, e);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -37,6 +39,9 @@ public class UserErrorLogService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserErrorLogService.class);
|
||||
|
||||
// 记录已经记录过错误日志的任务ID,避免重复记录
|
||||
private final Set<String> loggedErrorTaskIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Autowired
|
||||
private UserErrorLogRepository userErrorLogRepository;
|
||||
|
||||
@@ -44,11 +49,20 @@ public class UserErrorLogService {
|
||||
|
||||
/**
|
||||
* 异步记录错误日志(推荐使用,不阻塞主流程)
|
||||
* 自动去重:同一个taskId只记录一次
|
||||
*/
|
||||
@Async
|
||||
public void logErrorAsync(String username, ErrorType errorType, String errorMessage,
|
||||
String errorSource, String taskId, String taskType) {
|
||||
try {
|
||||
// 如果有taskId,检查是否已经记录过
|
||||
if (taskId != null && !taskId.isEmpty()) {
|
||||
if (!loggedErrorTaskIds.add(taskId)) {
|
||||
// 已经记录过,跳过
|
||||
logger.debug("错误日志已记录过,跳过重复记录: taskId={}", taskId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
logError(username, errorType, errorMessage, errorSource, taskId, taskType, null, null);
|
||||
} catch (Exception e) {
|
||||
logger.error("异步记录错误日志失败: {}", e.getMessage());
|
||||
@@ -211,12 +225,23 @@ public class UserErrorLogService {
|
||||
*/
|
||||
public Map<String, Object> getErrorStatistics(int days) {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
LocalDateTime startTime = LocalDateTime.now().minusDays(days);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime startTime = now.minusDays(days);
|
||||
|
||||
// 总错误数
|
||||
long totalErrors = userErrorLogRepository.countByCreatedAtBetween(startTime, LocalDateTime.now());
|
||||
// 总错误数(指定天数内)
|
||||
long totalErrors = userErrorLogRepository.countByCreatedAtBetween(startTime, now);
|
||||
stats.put("totalErrors", totalErrors);
|
||||
|
||||
// 今日错误数
|
||||
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
|
||||
long todayErrors = userErrorLogRepository.countByCreatedAtBetween(todayStart, now);
|
||||
stats.put("todayErrors", todayErrors);
|
||||
|
||||
// 本周错误数
|
||||
LocalDateTime weekStart = now.minusDays(7);
|
||||
long weekErrors = userErrorLogRepository.countByCreatedAtBetween(weekStart, now);
|
||||
stats.put("weekErrors", weekErrors);
|
||||
|
||||
// 按类型统计
|
||||
List<Object[]> byType = userErrorLogRepository.countByErrorType(startTime);
|
||||
Map<String, Long> errorsByType = new LinkedHashMap<>();
|
||||
@@ -247,19 +272,6 @@ public class UserErrorLogService {
|
||||
}
|
||||
stats.put("errorsByDate", errorsByDate);
|
||||
|
||||
// 错误最多的用户(Top 10)
|
||||
List<Object[]> byUser = userErrorLogRepository.countByUsername(startTime);
|
||||
Map<String, Long> topErrorUsers = new LinkedHashMap<>();
|
||||
int count = 0;
|
||||
for (Object[] row : byUser) {
|
||||
if (count >= 10) break;
|
||||
String username = (String) row[0];
|
||||
Long errorCount = (Long) row[1];
|
||||
topErrorUsers.put(username, errorCount);
|
||||
count++;
|
||||
}
|
||||
stats.put("topErrorUsers", topErrorUsers);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import com.example.demo.config.CacheConfig;
|
||||
import com.example.demo.model.PointsFreezeRecord;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.PointsFreezeRecordRepository;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import com.example.demo.util.UserIdGenerator;
|
||||
|
||||
@Service
|
||||
@@ -30,18 +34,21 @@ public class UserService {
|
||||
private final com.example.demo.repository.OrderRepository orderRepository;
|
||||
private final com.example.demo.repository.PaymentRepository paymentRepository;
|
||||
private final CacheManager cacheManager;
|
||||
private final MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder,
|
||||
PointsFreezeRecordRepository pointsFreezeRecordRepository,
|
||||
com.example.demo.repository.OrderRepository orderRepository,
|
||||
com.example.demo.repository.PaymentRepository paymentRepository,
|
||||
CacheManager cacheManager) {
|
||||
CacheManager cacheManager,
|
||||
MembershipLevelRepository membershipLevelRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.pointsFreezeRecordRepository = pointsFreezeRecordRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.cacheManager = cacheManager;
|
||||
this.membershipLevelRepository = membershipLevelRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -53,14 +60,14 @@ public class UserService {
|
||||
throw new IllegalArgumentException("邮箱已被使用");
|
||||
}
|
||||
User user = new User();
|
||||
// 生成唯一用户ID,重试最多3次确保唯一性
|
||||
// 生成唯一用户ID,重试最多10次确保唯一性
|
||||
String userId = null;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
userId = UserIdGenerator.generate();
|
||||
if (!userRepository.existsByUserId(userId)) {
|
||||
break;
|
||||
}
|
||||
logger.warn("用户ID冲突,重新生成: {}", userId);
|
||||
logger.warn("用户ID冲突,重新生成: userId={}", userId);
|
||||
}
|
||||
user.setUserId(userId);
|
||||
user.setUsername(username);
|
||||
@@ -93,6 +100,12 @@ public class UserService {
|
||||
return userRepository.findByUsername(username).orElse(null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean existsByUserId(String userId) {
|
||||
return userRepository.existsByUserId(userId);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public User create(String username, String email, String rawPassword) {
|
||||
return register(username, email, rawPassword);
|
||||
@@ -506,13 +519,20 @@ public class UserService {
|
||||
history.add(record);
|
||||
}
|
||||
|
||||
// 2. 也检查已完成订单(作为补充,以防有订单但没有支付记录的情况)
|
||||
java.util.List<com.example.demo.model.Order> completedOrders = orderRepository.findByUserIdAndStatus(
|
||||
// 2. 也检查已支付和已完成订单(作为补充,以防有订单但没有支付记录的情况)
|
||||
java.util.List<com.example.demo.model.Order> paidOrders = new java.util.ArrayList<>();
|
||||
// 包含PAID状态的订单
|
||||
paidOrders.addAll(orderRepository.findByUserIdAndStatus(
|
||||
user.getId(),
|
||||
com.example.demo.model.OrderStatus.PAID
|
||||
));
|
||||
// 包含COMPLETED状态的订单
|
||||
paidOrders.addAll(orderRepository.findByUserIdAndStatus(
|
||||
user.getId(),
|
||||
com.example.demo.model.OrderStatus.COMPLETED
|
||||
);
|
||||
));
|
||||
|
||||
for (com.example.demo.model.Order order : completedOrders) {
|
||||
for (com.example.demo.model.Order order : paidOrders) {
|
||||
// 检查是否已经在支付记录中处理过(避免重复)
|
||||
boolean alreadyProcessed = successfulPayments.stream()
|
||||
.anyMatch(p -> p.getOrderId() != null && p.getOrderId().equals(order.getOrderNumber()));
|
||||
@@ -643,25 +663,36 @@ public class UserService {
|
||||
String description = payment.getDescription() != null ? payment.getDescription() : "";
|
||||
Integer pointsToAdd = 0;
|
||||
|
||||
// 从membership_levels表读取价格和积分配置(必须从数据库获取,禁止硬编码)
|
||||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
// 优先从描述中识别套餐类型
|
||||
if (description.contains("标准版") || description.contains("standard") ||
|
||||
description.contains("Standard") || description.contains("STANDARD")) {
|
||||
// 标准版订阅 - 200积分
|
||||
pointsToAdd = 200;
|
||||
pointsToAdd = standardPoints;
|
||||
} else if (description.contains("专业版") || description.contains("premium") ||
|
||||
description.contains("Premium") || description.contains("PREMIUM")) {
|
||||
// 专业版订阅 - 1000积分
|
||||
pointsToAdd = 1000;
|
||||
description.contains("Premium") || description.contains("PREMIUM") ||
|
||||
description.contains("professional") || description.contains("Professional")) {
|
||||
pointsToAdd = proPoints;
|
||||
} else {
|
||||
// 如果描述中没有套餐信息,根据金额判断
|
||||
// 标准版订阅 (59-258元) - 200积分
|
||||
if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0 &&
|
||||
amount.compareTo(new java.math.BigDecimal("259.00")) < 0) {
|
||||
pointsToAdd = 200;
|
||||
}
|
||||
// 专业版订阅 (259元以上) - 1000积分
|
||||
else if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
|
||||
pointsToAdd = 1000;
|
||||
int amountInt = amount.intValue();
|
||||
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
|
||||
pointsToAdd = proPoints;
|
||||
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
|
||||
pointsToAdd = standardPoints;
|
||||
} else if (amountInt >= proPrice) {
|
||||
pointsToAdd = proPoints;
|
||||
} else if (amountInt >= standardPrice) {
|
||||
pointsToAdd = standardPoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,4 +720,48 @@ public class UserService {
|
||||
public long countOnlineUsers() {
|
||||
return countOnlineUsers(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过期会员:将状态改为EXPIRED,清零积分
|
||||
* @return 处理的过期会员数量
|
||||
*/
|
||||
@Transactional
|
||||
public int processExpiredMemberships(com.example.demo.repository.UserMembershipRepository userMembershipRepository) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
java.util.List<com.example.demo.model.UserMembership> expiredMemberships =
|
||||
userMembershipRepository.findByStatusAndEndDateBefore("ACTIVE", now);
|
||||
|
||||
int processedCount = 0;
|
||||
for (com.example.demo.model.UserMembership membership : expiredMemberships) {
|
||||
try {
|
||||
// 更新会员状态为EXPIRED
|
||||
membership.setStatus("EXPIRED");
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
|
||||
// 清零用户积分
|
||||
java.util.Optional<User> userOpt = userRepository.findById(membership.getUserId());
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
int oldPoints = user.getPoints();
|
||||
if (oldPoints > 0) {
|
||||
user.setPoints(0);
|
||||
userRepository.save(user);
|
||||
logger.info("✅ 会员过期积分清零: userId={}, 原积分={}", membership.getUserId(), oldPoints);
|
||||
}
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
logger.info("✅ 处理过期会员: userId={}, endDate={}", membership.getUserId(), membership.getEndDate());
|
||||
} catch (Exception e) {
|
||||
logger.error("处理过期会员失败: userId={}", membership.getUserId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (processedCount > 0) {
|
||||
logger.info("✅ 共处理 {} 个过期会员", processedCount);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,14 @@ public class VerificationCodeService {
|
||||
// 生成验证码
|
||||
String code = generateVerificationCode();
|
||||
|
||||
// 先存储验证码到内存(确保发送前已存储)
|
||||
String codeKey = "email_code:" + normEmail;
|
||||
verificationCodes.put(codeKey, code);
|
||||
logger.info("验证码已存储 - 邮箱: {}, codeKey: {}, 验证码: {}", normEmail, codeKey, code);
|
||||
|
||||
// 发送邮件
|
||||
boolean success = sendEmail(normEmail, code);
|
||||
if (success) {
|
||||
// 存储验证码到内存
|
||||
String codeKey = "email_code:" + normEmail;
|
||||
verificationCodes.put(codeKey, code);
|
||||
|
||||
// 设置发送频率限制
|
||||
rateLimits.put(rateLimitKey, System.currentTimeMillis());
|
||||
|
||||
@@ -113,8 +114,12 @@ public class VerificationCodeService {
|
||||
logger.info("验证码已过期,邮箱: {}", normEmail);
|
||||
}, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
|
||||
logger.info("邮件验证码发送成功,邮箱: {}", normEmail);
|
||||
logger.info("邮件验证码发送成功,邮箱: {}, 验证码: {}", normEmail, code);
|
||||
return true;
|
||||
} else {
|
||||
// 发送失败,移除已存储的验证码
|
||||
verificationCodes.remove(codeKey);
|
||||
logger.warn("邮件发送失败,已移除验证码,邮箱: {}", normEmail);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -134,6 +139,9 @@ public class VerificationCodeService {
|
||||
String codeKey = "email_code:" + normEmail;
|
||||
String storedCode = verificationCodes.get(codeKey);
|
||||
|
||||
logger.info("验证码验证调试 - 邮箱: {}, codeKey: {}, 存储码: {}, 输入码: {}",
|
||||
normEmail, codeKey, storedCode, code);
|
||||
|
||||
if (storedCode != null && storedCode.equals(code)) {
|
||||
// 验证成功后删除验证码
|
||||
verificationCodes.remove(codeKey);
|
||||
@@ -141,7 +149,7 @@ public class VerificationCodeService {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn("邮件验证码验证失败,邮箱: {}, 输入码: {}", normEmail, code);
|
||||
logger.warn("邮件验证码验证失败,邮箱: {}, 输入码: {}, 存储码: {}", normEmail, code, storedCode);
|
||||
return false;
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -6,25 +6,24 @@ import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 用户ID生成器
|
||||
* 格式: UID + yyMMdd + 4位随机字符
|
||||
* 示例: UID241204X7K9
|
||||
* 格式: yyMMdd + 5位随机数字
|
||||
* 示例: 24121112345
|
||||
*/
|
||||
public class UserIdGenerator {
|
||||
|
||||
private static final String PREFIX = "UID";
|
||||
// 去除易混淆字符: 0/O, 1/I/L
|
||||
private static final String CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
||||
// 只使用数字
|
||||
private static final String CHARS = "0123456789";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd");
|
||||
|
||||
/**
|
||||
* 生成用户ID
|
||||
* @return 格式: UID + yyMMdd + 5位随机字符 (共15位)
|
||||
* @return 格式: yyMMdd + 5位随机数字 (共11位)
|
||||
*/
|
||||
public static String generate() {
|
||||
String datePart = LocalDate.now().format(DATE_FORMAT);
|
||||
String randomPart = generateRandomString(5);
|
||||
return PREFIX + datePart + randomPart;
|
||||
return datePart + randomPart;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,19 +41,10 @@ public class UserIdGenerator {
|
||||
* 验证用户ID格式是否正确
|
||||
*/
|
||||
public static boolean isValid(String userId) {
|
||||
if (userId == null || userId.length() != 15) {
|
||||
if (userId == null || userId.length() != 11) {
|
||||
return false;
|
||||
}
|
||||
if (!userId.startsWith(PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
// 检查日期部分(6位数字)
|
||||
String datePart = userId.substring(3, 9);
|
||||
if (!datePart.matches("\\d{6}")) {
|
||||
return false;
|
||||
}
|
||||
// 检查随机部分(5位字母数字)
|
||||
String randomPart = userId.substring(9);
|
||||
return randomPart.matches("[A-Z0-9]{5}");
|
||||
// 检查是否全为数字
|
||||
return userId.matches("\\d{11}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#Updated by API Key Management
|
||||
#Tue Dec 09 15:23:38 CST 2025
|
||||
#Thu Dec 18 13:00:49 CST 2025
|
||||
ai.api.base-url=https\://ai.comfly.chat
|
||||
ai.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||
ai.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
|
||||
ai.image.api.base-url=https\://ai.comfly.chat
|
||||
ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||
ai.image.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
|
||||
alipay.app-id=9021000157616562
|
||||
alipay.charset=UTF-8
|
||||
alipay.domain=https\://vionow.com
|
||||
|
||||
@@ -16,6 +16,9 @@ server.tomcat.max-http-post-size=600MB
|
||||
|
||||
# JPA配置 - 禁用open-in-view避免视图层执行SQL查询
|
||||
spring.jpa.open-in-view=false
|
||||
# JPA自动更新表结构
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=false
|
||||
|
||||
# HikariCP连接池配置
|
||||
# 连接泄漏检测阈值(毫秒),设置为0禁用检测,避免长时间任务触发误报
|
||||
@@ -60,7 +63,7 @@ spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.R
|
||||
|
||||
# AI API配置
|
||||
ai.api.base-url=http://116.62.4.26:8081
|
||||
ai.api.key=ak_5f13ec469e6047d5b8155c3cc91350e2
|
||||
ai.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
|
||||
|
||||
# SpringDoc OpenAPI (Swagger) 配置
|
||||
springdoc.api-docs.path=/v3/api-docs
|
||||
|
||||
Reference in New Issue
Block a user