feat: 配额优化
This commit is contained in:
79
AGENTS.md
79
AGENTS.md
@@ -17,82 +17,3 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
<skills_system priority="1">
|
||||
|
||||
## Available Skills
|
||||
|
||||
<!-- SKILLS_TABLE_START -->
|
||||
<usage>
|
||||
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
||||
|
||||
How to use skills:
|
||||
- Invoke: Bash("openskills read <skill-name>")
|
||||
- The skill content will load with detailed instructions on how to complete the task
|
||||
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
|
||||
|
||||
Usage notes:
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already loaded in your context
|
||||
- Each skill invocation is stateless
|
||||
</usage>
|
||||
|
||||
<available_skills>
|
||||
|
||||
<skill>
|
||||
<name>algorithmic-art</name>
|
||||
<description>Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>brand-guidelines</name>
|
||||
<description>Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>docx</name>
|
||||
<description>"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>frontend-design</name>
|
||||
<description>Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>mcp-builder</name>
|
||||
<description>Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>skill-creator</name>
|
||||
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>slack-gif-creator</name>
|
||||
<description>Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>theme-factory</name>
|
||||
<description>Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>xlsx</name>
|
||||
<description>"Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas"</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
</available_skills>
|
||||
<!-- SKILLS_TABLE_END -->
|
||||
|
||||
</skills_system>
|
||||
|
||||
61
frontend/app/web-gold/src/api/modelConfig.js
Normal file
61
frontend/app/web-gold/src/api/modelConfig.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* AI模型配置 API 服务
|
||||
* 用于获取模型积分消耗配置
|
||||
*/
|
||||
|
||||
import http from './http'
|
||||
|
||||
const BASE_URL = '/webApi/api/tik/ai-model-config'
|
||||
|
||||
/**
|
||||
* 模型配置 API 服务
|
||||
*/
|
||||
export const ModelConfigService = {
|
||||
/**
|
||||
* 获取所有启用的模型配置列表(按平台分组)
|
||||
* @returns {Promise<Object>} 按平台分组的模型配置
|
||||
* 格式: { platform: [{ modelCode, modelName, consumePoints }] }
|
||||
*/
|
||||
async getEnabledModelConfigList() {
|
||||
const { data } = await http.get(`${BASE_URL}/list-enabled`)
|
||||
return data || {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据模型代码获取积分消耗
|
||||
* @param {Object} configMap - 配置映射(从 getEnabledModelConfigList 获取)
|
||||
* @param {string} modelCode - 模型代码
|
||||
* @returns {number|null} 积分消耗,未找到返回 null
|
||||
*/
|
||||
getConsumePoints(configMap, modelCode) {
|
||||
if (!configMap || !modelCode) return null
|
||||
|
||||
for (const platform of Object.values(configMap)) {
|
||||
const model = platform?.find(m => m.modelCode === modelCode)
|
||||
if (model) {
|
||||
return model.consumePoints
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据模型代码获取模型名称
|
||||
* @param {Object} configMap - 配置映射
|
||||
* @param {string} modelCode - 模型代码
|
||||
* @returns {string|null} 模型名称,未找到返回 null
|
||||
*/
|
||||
getModelName(configMap, modelCode) {
|
||||
if (!configMap || !modelCode) return null
|
||||
|
||||
for (const platform of Object.values(configMap)) {
|
||||
const model = platform?.find(m => m.modelCode === modelCode)
|
||||
if (model) {
|
||||
return model.modelName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default ModelConfigService
|
||||
100
frontend/app/web-gold/src/components/PointsTag.vue
Normal file
100
frontend/app/web-gold/src/components/PointsTag.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<span v-if="displayText" :class="['points-tag', `points-tag--${size}`, { 'points-tag--free': isFree }]">
|
||||
<span v-if="showIcon" class="points-tag__icon">⚡</span>
|
||||
<span class="points-tag__text">{{ displayText }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
|
||||
const props = defineProps({
|
||||
// 模型代码
|
||||
modelCode: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 直接传入积分数(优先级高于 modelCode)
|
||||
points: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// 尺寸
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default', // small, default, large
|
||||
validator: (value) => ['small', 'default', 'large'].includes(value)
|
||||
},
|
||||
// 是否显示图标
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
|
||||
// 获取积分数
|
||||
const consumePoints = computed(() => {
|
||||
if (props.points !== null) {
|
||||
return props.points
|
||||
}
|
||||
if (props.modelCode) {
|
||||
return pointsConfigStore.getConsumePoints(props.modelCode)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 是否免费
|
||||
const isFree = computed(() => consumePoints.value === 0)
|
||||
|
||||
// 显示文本
|
||||
const displayText = computed(() => {
|
||||
if (consumePoints.value === null) {
|
||||
return ''
|
||||
}
|
||||
return pointsConfigStore.formatPoints(consumePoints.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.points-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
|
||||
&--small {
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&--default {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--large {
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&--free {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
&__text {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="drawer-fade">
|
||||
<div v-if="visible" class="chat-overlay" @click.self="handleOverlayClick">
|
||||
<div v-if="visible" class="chat-overlay" @click.self="handleClose">
|
||||
<div class="chat-drawer" :class="{ 'chat-drawer--visible': visible }" @click.stop>
|
||||
<!-- Background Effect -->
|
||||
<div class="drawer-bg-pattern"></div>
|
||||
@@ -104,7 +104,7 @@
|
||||
@click="modelMode = 'standard'"
|
||||
>
|
||||
标准
|
||||
<span class="model-cost">-10 积分</span>
|
||||
<PointsTag :points="10" size="small" />
|
||||
</button>
|
||||
<button
|
||||
class="model-tab pro"
|
||||
@@ -113,7 +113,7 @@
|
||||
>
|
||||
<ThunderboltFilled />
|
||||
深度
|
||||
<span class="model-cost">-50 积分</span>
|
||||
<PointsTag :points="50" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import {
|
||||
CloseOutlined,
|
||||
RobotOutlined,
|
||||
@@ -173,6 +173,15 @@ import { message, Modal } from 'ant-design-vue'
|
||||
import { sendChatStream } from '@/api/agent'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import HistoryPanel from './HistoryPanel.vue'
|
||||
import PointsTag from '@/components/PointsTag.vue'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
|
||||
// 在组件挂载时加载积分配置
|
||||
onMounted(() => {
|
||||
pointsConfigStore.loadConfig()
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -232,8 +241,6 @@ const doClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => handleClose()
|
||||
|
||||
const adjustTextareaHeight = () => {
|
||||
const el = textareaRef.value
|
||||
if (el) {
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// GB转字节常量
|
||||
const GB_TO_BYTES = 1073741824
|
||||
|
||||
/**
|
||||
* 获取视频时长(秒)
|
||||
@@ -140,6 +144,20 @@ export function useUpload() {
|
||||
state.error = null
|
||||
state.progress = 0
|
||||
|
||||
// 存储空间校验
|
||||
const userStore = useUserStore()
|
||||
const fileSizeGB = file.size / GB_TO_BYTES
|
||||
const remainingStorage = userStore.remainingStorage || 0
|
||||
|
||||
if (fileSizeGB > remainingStorage) {
|
||||
const error = new Error(`存储空间不足!需要 ${fileSizeGB.toFixed(2)} GB,剩余 ${remainingStorage.toFixed(2)} GB`)
|
||||
state.uploading = false
|
||||
state.status = 'error'
|
||||
state.error = error.message
|
||||
onError?.(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 通知开始
|
||||
onStart?.()
|
||||
|
||||
|
||||
162
frontend/app/web-gold/src/stores/pointsConfig.js
Normal file
162
frontend/app/web-gold/src/stores/pointsConfig.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 积分配置 Store
|
||||
* 管理AI模型积分消耗配置
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getJSON, setJSON } from '@/utils/storage'
|
||||
import ModelConfigService from '@/api/modelConfig'
|
||||
|
||||
const STORAGE_KEY = 'points_config_v1'
|
||||
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存
|
||||
|
||||
export const usePointsConfigStore = defineStore('pointsConfig', () => {
|
||||
// 模型配置映射(按平台分组)
|
||||
const configMap = ref({})
|
||||
// 是否已加载
|
||||
const isLoaded = ref(false)
|
||||
// 上次加载时间
|
||||
const lastLoadTime = ref(0)
|
||||
// 是否正在加载
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 所有平台列表
|
||||
const platforms = computed(() => Object.keys(configMap.value))
|
||||
|
||||
// 获取所有模型列表(扁平化)
|
||||
const allModels = computed(() => {
|
||||
const models = []
|
||||
for (const [platform, list] of Object.entries(configMap.value)) {
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(model => {
|
||||
models.push({ ...model, platform })
|
||||
})
|
||||
}
|
||||
}
|
||||
return models
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据模型代码获取积分消耗
|
||||
* @param {string} modelCode - 模型代码
|
||||
* @returns {number|null} 积分消耗
|
||||
*/
|
||||
const getConsumePoints = (modelCode) => {
|
||||
if (!modelCode) return null
|
||||
return ModelConfigService.getConsumePoints(configMap.value, modelCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型代码获取模型名称
|
||||
* @param {string} modelCode - 模型代码
|
||||
* @returns {string|null} 模型名称
|
||||
*/
|
||||
const getModelName = (modelCode) => {
|
||||
if (!modelCode) return null
|
||||
return ModelConfigService.getModelName(configMap.value, modelCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型完整信息
|
||||
* @param {string} modelCode - 模型代码
|
||||
* @returns {Object|null} 模型信息 { modelCode, modelName, consumePoints, platform }
|
||||
*/
|
||||
const getModelInfo = (modelCode) => {
|
||||
if (!modelCode) return null
|
||||
for (const [platform, list] of Object.entries(configMap.value)) {
|
||||
if (Array.isArray(list)) {
|
||||
const model = list.find(m => m.modelCode === modelCode)
|
||||
if (model) {
|
||||
return { ...model, platform }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储恢复
|
||||
*/
|
||||
const hydrateFromStorage = async () => {
|
||||
const saved = await getJSON(STORAGE_KEY)
|
||||
if (saved && saved.configMap) {
|
||||
configMap.value = saved.configMap
|
||||
lastLoadTime.value = saved.lastLoadTime || 0
|
||||
isLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化到本地存储
|
||||
*/
|
||||
const persistToStorage = async () => {
|
||||
await setJSON(STORAGE_KEY, {
|
||||
configMap: configMap.value,
|
||||
lastLoadTime: lastLoadTime.value,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模型配置(从服务器)
|
||||
* @param {boolean} force - 是否强制刷新
|
||||
*/
|
||||
const loadConfig = async (force = false) => {
|
||||
// 检查缓存是否有效
|
||||
const now = Date.now()
|
||||
if (!force && isLoaded.value && (now - lastLoadTime.value) < CACHE_DURATION) {
|
||||
return configMap.value
|
||||
}
|
||||
|
||||
// 防止重复加载
|
||||
if (isLoading.value) {
|
||||
return configMap.value
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await ModelConfigService.getEnabledModelConfigList()
|
||||
configMap.value = data || {}
|
||||
lastLoadTime.value = now
|
||||
isLoaded.value = true
|
||||
await persistToStorage()
|
||||
return configMap.value
|
||||
} catch (error) {
|
||||
console.error('[pointsConfig] 加载模型配置失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化积分显示
|
||||
* @param {number} points - 积分数
|
||||
* @returns {string} 格式化后的字符串
|
||||
*/
|
||||
const formatPoints = (points) => {
|
||||
if (points === null || points === undefined) return ''
|
||||
if (points === 0) return '免费'
|
||||
return `${points}积分`
|
||||
}
|
||||
|
||||
// 初始化时从本地恢复
|
||||
hydrateFromStorage()
|
||||
|
||||
return {
|
||||
// 状态
|
||||
configMap,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
platforms,
|
||||
allModels,
|
||||
// 方法
|
||||
getConsumePoints,
|
||||
getModelName,
|
||||
getModelInfo,
|
||||
loadConfig,
|
||||
formatPoints,
|
||||
}
|
||||
})
|
||||
|
||||
export default usePointsConfigStore
|
||||
@@ -1,6 +1,14 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
|
||||
// 加载积分配置
|
||||
onMounted(() => {
|
||||
pointsConfigStore.loadConfig()
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -65,6 +73,7 @@ function handleReset() {
|
||||
/>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
<p class="points-hint">每次分析将消耗积分,消耗量与分析数量相关</p>
|
||||
</a-form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -88,6 +97,13 @@ function handleReset() {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.points-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.ant-slider) {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 试听优化:添加缓存机制,同一参数下第二次试听直接播放缓存,无需重复调用API
|
||||
*/
|
||||
defineOptions({ name: 'DigitalVideoPage' })
|
||||
import { ref, computed, onMounted, watch, onUnmounted, onActivated } from 'vue'
|
||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { InboxOutlined, SoundOutlined, LoadingOutlined } from '@ant-design/icons-vue'
|
||||
import { VoiceService } from '@/api/voice'
|
||||
@@ -18,7 +18,9 @@ import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
|
||||
|
||||
// 导入 voiceStore 用于获取用户音色
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
const voiceStore = useVoiceCopyStore()
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
|
||||
// 状态管理
|
||||
const uploadedVideo = ref('')
|
||||
@@ -36,6 +38,10 @@ const playingPreviewVoiceId = ref('') // 当前正在试听的音色ID
|
||||
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
|
||||
const pollingInterval = ref(null) // 轮询间隔ID
|
||||
|
||||
// 音频播放实例
|
||||
let previewAudio = null
|
||||
let previewObjectUrl = ''
|
||||
|
||||
// Upload Hook
|
||||
const { upload } = useUpload()
|
||||
|
||||
@@ -760,6 +766,8 @@ const playAudioFromBase64 = (audioBase64, format = 'mp3', onEnded = null) => {
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
// 加载积分配置
|
||||
pointsConfigStore.loadConfig()
|
||||
|
||||
await voiceStore.refresh()
|
||||
// 默认选择第一个音色
|
||||
@@ -830,9 +838,6 @@ watch([ttsText, speechRate, instruction, emotion], () => {
|
||||
console.log('试听参数已变化,清除缓存')
|
||||
})
|
||||
|
||||
// 音频实例
|
||||
let previewAudio = null
|
||||
let previewObjectUrl = ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1032,6 +1037,7 @@ let previewObjectUrl = ''
|
||||
>
|
||||
{{ isGenerating ? '生成中...' : '生成视频' }}
|
||||
</a-button>
|
||||
<p class="points-hint">生成视频将消耗积分,消耗量与视频时长相关</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="task-actions">
|
||||
@@ -1440,6 +1446,13 @@ let previewObjectUrl = ''
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.points-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -82,6 +82,14 @@
|
||||
|
||||
<!-- 搜索和操作区 -->
|
||||
<div class="toolbar-actions">
|
||||
<!-- 存储配额显示 -->
|
||||
<div class="storage-quota">
|
||||
<span class="storage-quota__label">存储空间</span>
|
||||
<span class="storage-quota__value">{{ userStore.usedStorage.toFixed(2) }} / {{ userStore.totalStorage }} GB</span>
|
||||
<div class="storage-quota__bar">
|
||||
<div class="storage-quota__used" :style="{ width: `${Math.min((userStore.usedStorage / userStore.totalStorage) * 100, 100)}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<a-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索文件名..."
|
||||
@@ -273,6 +281,10 @@ import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
||||
import MaterialService, { MaterialGroupService } from '@/api/material';
|
||||
import { useUpload } from '@/composables/useUpload';
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
// 用户状态(获取存储配额)
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 状态管理
|
||||
const loading = ref(false)
|
||||
@@ -530,15 +542,6 @@ const handleFileClick = (file) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelectChange = (fileId, checked) => {
|
||||
const index = selectedFileIds.value.indexOf(fileId)
|
||||
if (checked && index === -1) {
|
||||
selectedFileIds.value.push(fileId)
|
||||
} else if (!checked && index > -1) {
|
||||
selectedFileIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenUploadModal = () => {
|
||||
uploadModalVisible.value = true
|
||||
}
|
||||
@@ -572,6 +575,8 @@ const handleFileUpload = async (filesWithCover, category, groupId) => {
|
||||
message.success(`成功上传 ${filesWithCover.length} 个文件`)
|
||||
uploadModalVisible.value = false
|
||||
await loadFileList()
|
||||
// 刷新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
// 混剪素材才刷新分组列表
|
||||
if (activeCategory.value === 'MIX') {
|
||||
await loadGroupList()
|
||||
@@ -633,6 +638,9 @@ const handleBatchDelete = async () => {
|
||||
totalFileCount.value = Math.max(0, totalFileCount.value - count)
|
||||
selectedFileIds.value = []
|
||||
|
||||
// 刷新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
|
||||
// 如果删除后当前页没有数据了,则加载上一页
|
||||
if (fileList.value.length === 0 && pagination.current > 1) {
|
||||
pagination.current = pagination.current - 1
|
||||
@@ -727,8 +735,10 @@ watch(activeCategory, () => {
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadGroupList()
|
||||
onMounted(async () => {
|
||||
// 刷新用户档案获取最新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
await loadGroupList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -987,6 +997,43 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 存储配额显示
|
||||
.storage-quota {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: @bg-page;
|
||||
border-radius: @radius-sm;
|
||||
border: 1px solid @border-color;
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
color: @text-muted;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: @text-primary;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: @border-color;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__used {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, @primary-color, #818cf8);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 工具栏操作区
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,7 @@ import TikhubService, { InterfaceType, MethodType, ParamType } from '@/api/tikhu
|
||||
import { rewriteStream } from '@/api/forecast'
|
||||
import { getAgentList } from '@/api/agent'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
import { getVoiceText } from '@gold/hooks/web/useVoiceText'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
@@ -13,6 +14,7 @@ defineOptions({ name: 'ForecastView' })
|
||||
|
||||
// 状态管理
|
||||
const userStore = useUserStore()
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
const searchKeyword = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
@@ -368,6 +370,7 @@ async function handleSearch() {
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadAgentList()
|
||||
pointsConfigStore.loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -604,6 +607,7 @@ onMounted(() => {
|
||||
<span>生成爆款</span>
|
||||
</template>
|
||||
</button>
|
||||
<p class="points-hint">生成爆款文案将消耗积分</p>
|
||||
|
||||
<!-- 生成结果 -->
|
||||
<Transition name="slide-up">
|
||||
@@ -848,10 +852,6 @@ onMounted(() => {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// 热点卡片
|
||||
.topic-list {
|
||||
display: flex;
|
||||
@@ -1205,6 +1205,13 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.points-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
// 结果区域
|
||||
.result-block {
|
||||
padding-top: 16px;
|
||||
|
||||
455
openspec/plans/points-oss-quota-system.md
Normal file
455
openspec/plans/points-oss-quota-system.md
Normal file
@@ -0,0 +1,455 @@
|
||||
# 积分系统与OSS额度系统实施计划
|
||||
|
||||
> 创建日期:2026-02-25
|
||||
> 状态:待审批
|
||||
|
||||
---
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
### 1.1 业务需求
|
||||
|
||||
| 模块 | 需求描述 |
|
||||
|------|----------|
|
||||
| **前端积分系统** | 前端启动时加载 `muye_ai_model_config`,根据 model_code 获取积分消耗,显示在对标分析、热点趋势、智能体、数字人模块 |
|
||||
| **OSS额度系统** | 后端统一用字节计算,仅统计已上传未删除文件,上传前校验,删除后释放,每日凌晨自动对账 |
|
||||
| **OSS存储管理** | 素材列表和个人中心显示用户正确的额度 |
|
||||
|
||||
### 1.2 验收标准
|
||||
|
||||
1. 前端各业务模块正确显示积分消耗数值
|
||||
2. OSS 上传前校验额度,超额拒绝上传
|
||||
3. OSS 文件删除后实时释放额度
|
||||
4. 每日凌晨自动对账,确保数据库记录与实际一致
|
||||
|
||||
---
|
||||
|
||||
## 二、现有架构分析
|
||||
|
||||
### 2.1 已有基础设施
|
||||
|
||||
| 组件 | 位置 | 状态 |
|
||||
|------|------|------|
|
||||
| **积分服务** | `PointsService` | ✅ 已实现预检、扣减、预扣逻辑 |
|
||||
| **AI模型配置** | `muye_ai_model_config` 表 | ✅ 已有 `consume_points` 字段 |
|
||||
| **用户档案服务** | `MemberUserProfileService` | ⚠️ 需增强存储校验和更新 |
|
||||
| **用户档案表** | `muye_member_user_profile` | ✅ 已有存储和积分字段(GB单位) |
|
||||
| **用户Store** | `stores/user.js` | ✅ 已有积分和存储计算属性 |
|
||||
| **个人中心** | `views/user/Profile.vue` | ✅ 已显示基本额度信息 |
|
||||
|
||||
### 2.2 需要新增的组件
|
||||
|
||||
| 组件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ModelConfigApi` | 前端API | 获取模型配置列表 |
|
||||
| `PointsConfigStore` | 前端Store | 管理积分配置状态 |
|
||||
| `PointsTag` | 前端组件 | 积分消耗标签组件 |
|
||||
| `OssQuotaReconcileJob` | 后端Job | 每日对账任务 |
|
||||
| `StorageQuotaMixin` | 前端Composable | 存储额度显示逻辑复用 |
|
||||
|
||||
---
|
||||
|
||||
## 三、详细设计
|
||||
|
||||
### 3.1 前端积分系统
|
||||
|
||||
#### 3.1.1 数据流
|
||||
|
||||
```
|
||||
前端启动 → 调用 API 获取模型配置 → 存入 Store → 各模块从 Store 读取显示
|
||||
```
|
||||
|
||||
#### 3.1.2 新增文件
|
||||
|
||||
```
|
||||
frontend/app/web-gold/src/
|
||||
├── api/
|
||||
│ └── modelConfig.js # 模型配置 API
|
||||
├── stores/
|
||||
│ └── pointsConfig.js # 积分配置 Store
|
||||
└── components/
|
||||
└── common/
|
||||
└── PointsTag.vue # 积分消耗标签组件
|
||||
```
|
||||
|
||||
#### 3.1.3 API 设计
|
||||
|
||||
```javascript
|
||||
// api/modelConfig.js
|
||||
export function getModelConfigList() {
|
||||
return request({
|
||||
url: `${BASE_URL}/aimodelconfig/list-enabled`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 返回格式
|
||||
{
|
||||
"dify": {
|
||||
"agent_chat_pro": { "consumePoints": 10, "modelName": "深度版" },
|
||||
"agent_chat_standard": { "consumePoints": 5, "modelName": "标准版" }
|
||||
},
|
||||
"digital_human": {
|
||||
"latentsync": { "consumePoints": 50, "modelName": "数字人" }
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.4 Store 设计
|
||||
|
||||
```javascript
|
||||
// stores/pointsConfig.js
|
||||
export const usePointsConfigStore = defineStore('pointsConfig', () => {
|
||||
const configMap = ref({}) // 按平台+modelCode组织的配置
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 根据 platform 和 modelCode 获取积分消耗
|
||||
const getConsumePoints = (platform, modelCode) => {
|
||||
return configMap.value[platform]?.[modelCode]?.consumePoints ?? 0
|
||||
}
|
||||
|
||||
// 初始化加载配置
|
||||
const loadConfig = async () => {
|
||||
if (isLoaded.value) return
|
||||
const data = await getModelConfigList()
|
||||
configMap.value = data
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
return { configMap, isLoaded, getConsumePoints, loadConfig }
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.1.5 积分标签组件
|
||||
|
||||
```vue
|
||||
<!-- components/common/PointsTag.vue -->
|
||||
<template>
|
||||
<span class="points-tag" :class="{ 'is-loading': loading }">
|
||||
<ThunderboltOutlined />
|
||||
<span class="points-value">{{ displayPoints }}</span>
|
||||
<span class="points-unit">积分</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
|
||||
const props = defineProps({
|
||||
platform: { type: String, required: true },
|
||||
modelCode: { type: String, required: true },
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
const displayPoints = computed(() =>
|
||||
pointsConfigStore.getConsumePoints(props.platform, props.modelCode)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 3.1.6 各模块集成点
|
||||
|
||||
| 模块 | 文件位置 | 集成方式 |
|
||||
|------|----------|----------|
|
||||
| **智能体** | `ChatDrawer.vue` | 在发送按钮旁显示积分消耗 |
|
||||
| **数字人** | `Video.vue` | 在生成按钮旁显示积分消耗 |
|
||||
| **对标分析** | `Benchmark.vue` | 在分析按钮旁显示积分消耗 |
|
||||
| **热点趋势** | `Forecast.vue` | 在文案生成按钮旁显示积分消耗 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 OSS 额度系统
|
||||
|
||||
#### 3.2.1 数据模型
|
||||
|
||||
**MemberUserProfileDO 字段(muye_member_user_profile 表):**
|
||||
```java
|
||||
private String userId; // 用户ID
|
||||
private BigDecimal totalStorage; // 云空间总容量 (GB)
|
||||
private BigDecimal usedStorage; // 云空间已用容量 (GB)
|
||||
private BigDecimal remainingStorage;// 云空间剩余容量 (GB)
|
||||
private Integer totalPoints; // 账户总积分
|
||||
private Integer usedPoints; // 账户消耗积分
|
||||
private Integer remainingPoints; // 账户剩余积分
|
||||
```
|
||||
|
||||
> **设计决策**:保持 GB 单位存储(兼容现有数据),后端逻辑统一用字节计算
|
||||
|
||||
**存储文件记录表(muye_material_file):**
|
||||
```sql
|
||||
-- 文件大小字段(字节)
|
||||
file_size BIGINT NOT NULL COMMENT '文件大小(字节)'
|
||||
```
|
||||
|
||||
**单位转换常量:**
|
||||
```java
|
||||
// 1 GB = 1024 * 1024 * 1024 字节
|
||||
public static final long BYTES_PER_GB = 1073741824L;
|
||||
```
|
||||
|
||||
#### 3.2.2 后端增强设计
|
||||
|
||||
**修改/新增文件:**
|
||||
|
||||
```
|
||||
yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/
|
||||
├── muye/
|
||||
│ └── memberuserprofile/
|
||||
│ ├── service/
|
||||
│ │ ├── MemberUserProfileService.java # 增强:添加存储额度方法
|
||||
│ │ └── MemberUserProfileServiceImpl.java
|
||||
│ ├── mapper/
|
||||
│ │ └── MemberUserProfileMapper.java # 增强:添加原子更新方法
|
||||
│ └── job/
|
||||
│ └── OssQuotaReconcileJob.java # 新增:每日对账Job
|
||||
```
|
||||
|
||||
#### 3.2.3 额度校验流程
|
||||
|
||||
```
|
||||
用户上传文件
|
||||
↓
|
||||
检查文件大小(字节)
|
||||
↓
|
||||
调用 MemberUserProfileService.validateStorage(userId, fileSizeBytes)
|
||||
↓
|
||||
内部逻辑:将 fileSizeBytes 转换为 GB,与 remainingStorage 比较
|
||||
↓
|
||||
├── 余额充足 → 允许上传
|
||||
│ ↓
|
||||
│ 文件上传成功 → 调用 increaseUsedStorage(userId, fileSizeBytes)
|
||||
│
|
||||
└── 余额不足 → 抛出异常,拒绝上传
|
||||
```
|
||||
|
||||
#### 3.2.4 额度释放流程
|
||||
|
||||
```
|
||||
用户删除文件
|
||||
↓
|
||||
获取文件大小 fileSizeBytes(字节)
|
||||
↓
|
||||
调用 MemberUserProfileService.decreaseUsedStorage(userId, fileSizeBytes)
|
||||
↓
|
||||
内部逻辑:字节转GB,原子更新 usedStorage/remainingStorage
|
||||
```
|
||||
|
||||
#### 3.2.5 MemberUserProfileService 增强
|
||||
|
||||
```java
|
||||
// 新增方法
|
||||
/**
|
||||
* 校验存储空间是否足够
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
*/
|
||||
void validateStorage(String userId, long fileSizeBytes);
|
||||
|
||||
/**
|
||||
* 增加已使用存储空间
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
*/
|
||||
void increaseUsedStorage(String userId, long fileSizeBytes);
|
||||
|
||||
/**
|
||||
* 减少已使用存储空间
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
*/
|
||||
void decreaseUsedStorage(String userId, long fileSizeBytes);
|
||||
```
|
||||
|
||||
#### 3.2.6 Mapper 原子更新
|
||||
|
||||
```java
|
||||
// MemberUserProfileMapper.java 新增
|
||||
|
||||
/**
|
||||
* 原子增加已用存储(乐观锁)
|
||||
* @param userId 用户ID
|
||||
* @param storageGb 增加的存储量(GB,BigDecimal转String)
|
||||
* @return 影响行数,0表示余额不足
|
||||
*/
|
||||
@Update("UPDATE muye_member_user_profile " +
|
||||
"SET used_storage = used_storage + #{storageGb}, " +
|
||||
" remaining_storage = remaining_storage - #{storageGb}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND remaining_storage >= #{storageGb}")
|
||||
int updateStorageIncrease(@Param("userId") String userId, @Param("storageGb") String storageGb);
|
||||
|
||||
/**
|
||||
* 原子减少已用存储
|
||||
*/
|
||||
@Update("UPDATE muye_member_user_profile " +
|
||||
"SET used_storage = used_storage - #{storageGb}, " +
|
||||
" remaining_storage = remaining_storage + #{storageGb}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND used_storage >= #{storageGb}")
|
||||
int updateStorageDecrease(@Param("userId") String userId, @Param("storageGb") String storageGb);
|
||||
```
|
||||
|
||||
#### 3.2.7 每日对账Job
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class OssQuotaReconcileJob {
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * ?") // 每日凌晨3点
|
||||
public void reconcile() {
|
||||
// 1. 查询所有用户的档案
|
||||
// 2. 统计每个用户 muye_material_file 表中文件总大小(字节)
|
||||
// 3. 转换为 GB,与 usedStorage 对比
|
||||
// 4. 不一致则修正并记录日志
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.8 API 设计
|
||||
|
||||
使用现有的 `/webApi/api/tik/member-profile/get` 接口,返回数据已包含存储额度信息。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 OSS 存储管理
|
||||
|
||||
#### 3.3.1 素材列表集成
|
||||
|
||||
在 `MaterialListNew.vue` 顶部工具栏显示存储额度:
|
||||
|
||||
```vue
|
||||
<div class="storage-quota-info">
|
||||
<DatabaseOutlined />
|
||||
<span>{{ formatStorage(usedStorage) }} / {{ formatStorage(totalStorage) }}</span>
|
||||
<a-progress :percent="storagePercent" size="small" />
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.3.2 个人中心优化
|
||||
|
||||
`Profile.vue` 已有存储空间显示,需要确保数据来源正确:
|
||||
- 从 `userStore.remainingStorage` 读取
|
||||
- 确保 `getUserProfile()` API 返回正确的字节数据
|
||||
|
||||
---
|
||||
|
||||
## 四、任务分解
|
||||
|
||||
### Phase 1: 后端基础(优先级:高)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1.1 | 新增模型配置列表API | `AppAiModelConfigController.java` | 添加 `/list-enabled` 接口 |
|
||||
| 1.2 | 增强用户档案服务 | `MemberUserProfileServiceImpl.java` | 添加存储校验和更新方法 |
|
||||
| 1.3 | 新增Mapper原子更新 | `MemberUserProfileMapper.java` | 添加存储增减的原子操作 |
|
||||
| 1.4 | 新增每日对账Job | `OssQuotaReconcileJob.java` | 实现自动对账 |
|
||||
|
||||
### Phase 2: 前端积分系统(优先级:高)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 2.1 | 创建模型配置API | `api/modelConfig.js` | 封装配置获取接口 |
|
||||
| 2.2 | 创建积分配置Store | `stores/pointsConfig.js` | 管理配置状态 |
|
||||
| 2.3 | 创建积分标签组件 | `components/common/PointsTag.vue` | 可复用的积分显示组件 |
|
||||
| 2.4 | 应用启动时加载配置 | `App.vue` 或入口文件 | 初始化积分配置 |
|
||||
|
||||
### Phase 3: 业务模块集成(优先级:中)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 3.1 | 智能体模块集成 | `ChatDrawer.vue` | 显示对话积分消耗 |
|
||||
| 3.2 | 数字人模块集成 | `dh/Video.vue` | 显示生成积分消耗 |
|
||||
| 3.3 | 对标分析集成 | `Benchmark.vue` | 显示分析积分消耗 |
|
||||
| 3.4 | 热点趋势集成 | `Forecast.vue` | 显示文案生成积分消耗 |
|
||||
|
||||
### Phase 4: OSS存储管理(优先级:中)
|
||||
|
||||
| # | 任务 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 4.1 | 素材列表显示额度 | `MaterialListNew.vue` | 工具栏显示存储额度 |
|
||||
| 4.2 | 上传前额度校验 | `useUpload.js` | 上传前检查额度 |
|
||||
| 4.3 | 删除后释放额度 | 后端文件删除接口 | 调用 decreaseUsedStorage |
|
||||
| 4.4 | 个人中心优化 | `Profile.vue` | 确保显示正确字节值 |
|
||||
|
||||
---
|
||||
|
||||
## 五、风险分析
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
|------|------|------|
|
||||
| 积分配置加载失败 | 前端显示0积分 | 添加默认值和重试机制 |
|
||||
| 并发上传导致额度超限 | 超出配额 | 使用数据库乐观锁或原子操作 |
|
||||
| 对账Job执行时间过长 | 影响系统性能 | 分批处理,添加超时控制 |
|
||||
| 历史数据不一致 | 对账修正幅度大 | 先做数据盘点,再逐步修正 |
|
||||
|
||||
---
|
||||
|
||||
## 六、测试计划
|
||||
|
||||
### 6.1 单元测试
|
||||
|
||||
- [ ] `PointsConfigStore` 状态管理测试
|
||||
- [ ] `TikUserQuotaService` 额度计算测试
|
||||
- [ ] 对账Job逻辑测试
|
||||
|
||||
### 6.2 集成测试
|
||||
|
||||
- [ ] 前端积分显示正确性
|
||||
- [ ] OSS上传额度校验
|
||||
- [ ] OSS删除额度释放
|
||||
- [ ] 每日对账执行
|
||||
|
||||
### 6.3 验收测试
|
||||
|
||||
```
|
||||
Benchark Checklist:
|
||||
1. ✅ 智能体对话页面显示"消耗 X 积分"
|
||||
2. ✅ 数字人生成页面显示"消耗 X 积分"
|
||||
3. ✅ 对标分析页面显示"消耗 X 积分"
|
||||
4. ✅ 热点趋势页面显示"消耗 X 积分"
|
||||
5. ✅ 素材列表显示存储额度进度条
|
||||
6. ✅ 上传大文件超出额度时提示错误
|
||||
7. ✅ 删除文件后额度正确释放
|
||||
8. ✅ 个人中心显示正确的存储额度
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实施顺序建议
|
||||
|
||||
```
|
||||
Week 1: Phase 1 (后端基础) + Phase 2 (前端积分系统)
|
||||
Week 2: Phase 3 (业务模块集成) + Phase 4 (OSS存储管理)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文件清单
|
||||
|
||||
### 后端文件
|
||||
|
||||
| 操作 | 文件路径 |
|
||||
|------|----------|
|
||||
| 修改 | `yudao-module-tik/.../aimodelconfig/AiModelConfigController.java` |
|
||||
| 修改 | `yudao-module-tik/.../memberuserprofile/service/MemberUserProfileService.java` |
|
||||
| 修改 | `yudao-module-tik/.../memberuserprofile/service/MemberUserProfileServiceImpl.java` |
|
||||
| 修改 | `yudao-module-tik/.../memberuserprofile/mapper/MemberUserProfileMapper.java` |
|
||||
| 新增 | `yudao-module-tik/.../memberuserprofile/job/OssQuotaReconcileJob.java` |
|
||||
|
||||
### 前端文件
|
||||
|
||||
| 操作 | 文件路径 |
|
||||
|------|----------|
|
||||
| 新增 | `frontend/app/web-gold/src/api/modelConfig.js` |
|
||||
| 新增 | `frontend/app/web-gold/src/stores/pointsConfig.js` |
|
||||
| 新增 | `frontend/app/web-gold/src/components/common/PointsTag.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/agents/ChatDrawer.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/dh/Video.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/content-style/Benchmark.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/trends/Forecast.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/views/material/MaterialListNew.vue` |
|
||||
| 修改 | `frontend/app/web-gold/src/composables/useUpload.js` |
|
||||
@@ -4,9 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO;
|
||||
import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -17,7 +15,7 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 用户 App - 测试 Controller
|
||||
* 用于测试升级会员和创建OSS目录
|
||||
* 用于测试创建OSS目录
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@@ -31,34 +29,6 @@ public class AppTikTestController {
|
||||
@Resource
|
||||
private TikOssInitService ossInitService;
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaService quotaService;
|
||||
|
||||
@PostMapping("/upgrade-vip")
|
||||
@Operation(summary = "测试:升级会员", description = "升级当前用户的VIP等级和配额")
|
||||
@Parameter(name = "vipLevel", description = "VIP等级", example = "1")
|
||||
@Parameter(name = "totalStorage", description = "总存储空间(字节)", example = "10737418240")
|
||||
@Parameter(name = "totalQuota", description = "总配额(积分/额度)", example = "10000")
|
||||
public CommonResult<Boolean> upgradeVip(
|
||||
@RequestParam(value = "vipLevel", defaultValue = "1") Integer vipLevel,
|
||||
@RequestParam(value = "totalStorage", required = false) Long totalStorage,
|
||||
@RequestParam(value = "totalQuota", required = false) Long totalQuota) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 默认值:10GB存储空间,10000配额
|
||||
if (totalStorage == null) {
|
||||
totalStorage = 10L * 1024 * 1024 * 1024; // 10GB
|
||||
}
|
||||
if (totalQuota == null) {
|
||||
totalQuota = 10000L;
|
||||
}
|
||||
|
||||
quotaService.updateVipLevel(userId, vipLevel, totalStorage, totalQuota);
|
||||
log.info("[upgradeVip][用户({})升级VIP成功,等级({}),存储({}),配额({})]",
|
||||
userId, vipLevel, totalStorage, totalQuota);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/init-oss")
|
||||
@Operation(summary = "测试:初始化OSS目录", description = "为当前用户创建OSS目录结构")
|
||||
public CommonResult<AppTikUserOssInitRespVO> initOss() {
|
||||
@@ -68,34 +38,5 @@ public class AppTikTestController {
|
||||
return success(result);
|
||||
}
|
||||
|
||||
@PostMapping("/test-all")
|
||||
@Operation(summary = "测试:一键执行", description = "同时执行升级会员和创建OSS目录")
|
||||
@Parameter(name = "vipLevel", description = "VIP等级", example = "1")
|
||||
@Parameter(name = "totalStorage", description = "总存储空间(字节)", example = "10737418240")
|
||||
@Parameter(name = "totalQuota", description = "总配额(积分/额度)", example = "10000")
|
||||
public CommonResult<AppTikUserOssInitRespVO> testAll(
|
||||
@RequestParam(value = "vipLevel", defaultValue = "1") Integer vipLevel,
|
||||
@RequestParam(value = "totalStorage", required = false) Long totalStorage,
|
||||
@RequestParam(value = "totalQuota", required = false) Long totalQuota) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 1. 升级会员
|
||||
if (totalStorage == null) {
|
||||
totalStorage = 10L * 1024 * 1024 * 1024; // 10GB
|
||||
}
|
||||
if (totalQuota == null) {
|
||||
totalQuota = 10000L;
|
||||
}
|
||||
quotaService.updateVipLevel(userId, vipLevel, totalStorage, totalQuota);
|
||||
log.info("[testAll][用户({})升级VIP成功,等级({}),存储({}),配额({})]",
|
||||
userId, vipLevel, totalStorage, totalQuota);
|
||||
|
||||
// 2. 初始化OSS目录
|
||||
AppTikUserOssInitRespVO ossResult = ossInitService.initOssDirectory(userId);
|
||||
log.info("[testAll][用户({})OSS目录初始化成功]", userId);
|
||||
|
||||
return success(ossResult);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户文件 Mapper
|
||||
@@ -32,4 +34,12 @@ public interface TikUserFileMapper extends BaseMapperX<TikUserFileDO> {
|
||||
.orderByDesc(TikUserFileDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计每个用户的实际存储使用量(字节)
|
||||
* @return Map<userId, totalSize>
|
||||
*/
|
||||
@Select("SELECT user_id, SUM(file_size) as total_size FROM tik_user_file " +
|
||||
"WHERE deleted = 0 GROUP BY user_id")
|
||||
List<Map<String, Object>> selectStorageByUser();
|
||||
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.enums.TikFileCategoryEnum;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFileRespVO;
|
||||
import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.service.MemberUserProfileService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -77,7 +77,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
private TikOssInitService ossInitService;
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaService quotaService;
|
||||
private MemberUserProfileService memberUserProfileService;
|
||||
|
||||
@Resource
|
||||
private FileApi fileApi;
|
||||
@@ -137,7 +137,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
// 2. 校验配额
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (fileSize != null && fileSize > 0) {
|
||||
quotaService.validateStorage(userId, fileSize);
|
||||
memberUserProfileService.validateStorage(String.valueOf(userId), fileSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
// 10. 更新配额
|
||||
quotaService.increaseUsedStorage(userId, file.getSize());
|
||||
memberUserProfileService.increaseUsedStorage(String.valueOf(userId), file.getSize());
|
||||
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]",
|
||||
userId, userFile.getId(), infraFileId);
|
||||
@@ -439,7 +439,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
// 释放配额
|
||||
long totalSize = files.stream().mapToLong(TikUserFileDO::getFileSize).sum();
|
||||
quotaService.decreaseUsedStorage(userId, totalSize);
|
||||
memberUserProfileService.decreaseUsedStorage(String.valueOf(userId), totalSize);
|
||||
|
||||
log.info("[deleteFiles][用户({})删除文件成功,文件数量({})]", userId, fileIds.size());
|
||||
}
|
||||
@@ -692,7 +692,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
// 6. 更新配额
|
||||
quotaService.increaseUsedStorage(userId, fileSize);
|
||||
memberUserProfileService.increaseUsedStorage(String.valueOf(userId), fileSize);
|
||||
|
||||
log.info("[completeUpload][用户({})直传上传完成,infraFileId({}),userFileId({})]",
|
||||
userId, infraFileId, userFile.getId());
|
||||
|
||||
@@ -2,7 +2,6 @@ package cn.iocoder.yudao.module.tik.member.mq.consumer;
|
||||
|
||||
import cn.iocoder.yudao.module.member.api.message.user.MemberUserCreateMessage;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.service.MemberUserProfileService;
|
||||
import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -12,7 +11,7 @@ import jakarta.annotation.Resource;
|
||||
/**
|
||||
* 会员用户创建事件监听器
|
||||
*
|
||||
* 功能:用户注册后自动初始化配额和档案
|
||||
* 功能:用户注册后自动初始化档案和配额
|
||||
* 触发时机:用户注册时(MemberUserServiceImpl.createUser())
|
||||
*
|
||||
* @author 芋道源码
|
||||
@@ -21,8 +20,6 @@ import jakarta.annotation.Resource;
|
||||
@Slf4j
|
||||
public class MemberUserCreateConsumer {
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaService quotaService;
|
||||
@Resource
|
||||
private MemberUserProfileService memberUserProfileService;
|
||||
|
||||
@@ -38,11 +35,7 @@ public class MemberUserCreateConsumer {
|
||||
|
||||
Long userId = message.getUserId();
|
||||
|
||||
// 初始化用户配额(默认VIP等级0)
|
||||
quotaService.initQuota(userId, 0);
|
||||
log.info("[onMessage][用户({})配额初始化成功]", userId);
|
||||
|
||||
// 初始化会员档案
|
||||
// 初始化会员档案(包含存储配额)
|
||||
memberUserProfileService.createIfAbsent(userId);
|
||||
log.info("[onMessage][用户({})档案初始化成功]", userId);
|
||||
}
|
||||
|
||||
@@ -101,4 +101,11 @@ public class AiModelConfigController {
|
||||
BeanUtils.toBean(list, AiModelConfigRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/list-enabled")
|
||||
@Operation(summary = "获取所有启用的模型配置列表(前端积分显示用)")
|
||||
@PreAuthorize("@ss.hasPermission('muye:ai-model-config:query')")
|
||||
public CommonResult<Map<String, List<AiModelConfigService.ModelConfigSimpleVO>>> getEnabledModelConfigList() {
|
||||
return success(aiModelConfigService.getEnabledModelConfigList());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.tik.muye.aimodelconfig;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.service.AiModelConfigService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 用户 App - AI模型配置
|
||||
*/
|
||||
@Tag(name = "用户 App - AI模型配置")
|
||||
@RestController
|
||||
@RequestMapping("/api/tik/ai-model-config")
|
||||
@Validated
|
||||
public class AppAiModelConfigController {
|
||||
|
||||
@Resource
|
||||
private AiModelConfigService aiModelConfigService;
|
||||
|
||||
@GetMapping("/list-enabled")
|
||||
@Operation(summary = "获取所有启用的模型配置列表(前端积分显示用)")
|
||||
public CommonResult<Map<String, List<AiModelConfigService.ModelConfigSimpleVO>>> getEnabledModelConfigList() {
|
||||
return success(aiModelConfigService.getEnabledModelConfigList());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,4 +49,13 @@ public interface AiModelConfigMapper extends BaseMapperX<AiModelConfigDO> {
|
||||
.eq(AiModelConfigDO::getStatus, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有启用的模型配置
|
||||
*/
|
||||
default List<AiModelConfigDO> selectEnabledList() {
|
||||
return selectList(new LambdaQueryWrapperX<AiModelConfigDO>()
|
||||
.eq(AiModelConfigDO::getStatus, 1)
|
||||
.orderByAsc(AiModelConfigDO::getPlatform, AiModelConfigDO::getModelCode));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.iocoder.yudao.module.tik.muye.aimodelconfig.service;
|
||||
|
||||
import java.util.*;
|
||||
import lombok.Data;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.aimodelconfig.vo.AiModelConfigPageReqVO;
|
||||
@@ -60,4 +61,21 @@ public interface AiModelConfigService {
|
||||
*/
|
||||
PageResult<AiModelConfigDO> getAiModelConfigPage(AiModelConfigPageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 获取所有启用的模型配置列表(前端积分显示用)
|
||||
*
|
||||
* @return 模型配置列表(按平台分组)
|
||||
*/
|
||||
Map<String, List<ModelConfigSimpleVO>> getEnabledModelConfigList();
|
||||
|
||||
/**
|
||||
* 启用的模型配置简单VO(用于前端显示)
|
||||
*/
|
||||
@Data
|
||||
class ModelConfigSimpleVO {
|
||||
private String modelCode;
|
||||
private String modelName;
|
||||
private Integer consumePoints;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -77,4 +77,18 @@ public class AiModelConfigServiceImpl implements AiModelConfigService {
|
||||
return aiModelConfigMapper.selectPage(pageReqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<ModelConfigSimpleVO>> getEnabledModelConfigList() {
|
||||
List<AiModelConfigDO> configList = aiModelConfigMapper.selectEnabledList();
|
||||
Map<String, List<ModelConfigSimpleVO>> result = new HashMap<>();
|
||||
for (AiModelConfigDO config : configList) {
|
||||
ModelConfigSimpleVO simpleVO = new ModelConfigSimpleVO();
|
||||
simpleVO.setModelCode(config.getModelCode());
|
||||
simpleVO.setModelName(config.getModelName());
|
||||
simpleVO.setConsumePoints(config.getConsumePoints());
|
||||
result.computeIfAbsent(config.getPlatform(), k -> new ArrayList<>()).add(simpleVO);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cn.iocoder.yudao.module.tik.muye.memberuserprofile.job;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.dal.MemberUserProfileDO;
|
||||
import cn.iocoder.yudao.module.tik.muye.memberuserprofile.mapper.MemberUserProfileMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OSS配额每日对账定时任务
|
||||
*
|
||||
* 功能:对比 tik_user_file 实际存储与 muye_member_user_profile 记录的存储
|
||||
* 如果存在差异,则更新 member_user_profile 表
|
||||
* 执行频率:每天凌晨3点执行
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class OssQuotaReconcileJob {
|
||||
|
||||
/**
|
||||
* GB转字节的常量
|
||||
*/
|
||||
private static final BigDecimal GB_TO_BYTES = new BigDecimal("1073741824");
|
||||
|
||||
@Resource
|
||||
private TikUserFileMapper userFileMapper;
|
||||
|
||||
@Resource
|
||||
private MemberUserProfileMapper profileMapper;
|
||||
|
||||
/**
|
||||
* 每天凌晨3点执行对账
|
||||
* cron: 秒 分 时 日 月 周
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 * * ?")
|
||||
public void reconcileStorage() {
|
||||
log.info("[reconcileStorage][开始OSS配额对账]");
|
||||
int updatedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
try {
|
||||
// 1. 获取每个用户的实际存储使用量(字节)
|
||||
List<Map<String, Object>> storageList = userFileMapper.selectStorageByUser();
|
||||
|
||||
for (Map<String, Object> record : storageList) {
|
||||
try {
|
||||
String userId = String.valueOf(record.get("user_id"));
|
||||
Long actualSizeBytes = ((Number) record.get("total_size")).longValue();
|
||||
|
||||
// 2. 获取用户档案中记录的存储
|
||||
MemberUserProfileDO profile = profileMapper.selectByUserId(userId);
|
||||
if (profile == null) {
|
||||
log.warn("[reconcileStorage][用户({})档案不存在,跳过]", userId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 将实际存储(字节)转换为GB
|
||||
BigDecimal actualSizeGb = new BigDecimal(actualSizeBytes)
|
||||
.divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP);
|
||||
|
||||
// 4. 对比记录的存储(GB)
|
||||
BigDecimal recordedSizeGb = profile.getUsedStorage();
|
||||
if (recordedSizeGb == null) {
|
||||
recordedSizeGb = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
// 5. 检查差异(允许0.01GB的误差)
|
||||
BigDecimal diff = actualSizeGb.subtract(recordedSizeGb).abs();
|
||||
if (diff.compareTo(new BigDecimal("0.01")) > 0) {
|
||||
// 存在差异,更新记录
|
||||
BigDecimal totalStorage = profile.getTotalStorage();
|
||||
if (totalStorage == null) {
|
||||
totalStorage = BigDecimal.ZERO;
|
||||
}
|
||||
BigDecimal remainingStorage = totalStorage.subtract(actualSizeGb);
|
||||
if (remainingStorage.compareTo(BigDecimal.ZERO) < 0) {
|
||||
remainingStorage = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
// 更新档案
|
||||
MemberUserProfileDO updateProfile = new MemberUserProfileDO();
|
||||
updateProfile.setId(profile.getId());
|
||||
updateProfile.setUsedStorage(actualSizeGb);
|
||||
updateProfile.setRemainingStorage(remainingStorage);
|
||||
profileMapper.updateById(updateProfile);
|
||||
|
||||
log.info("[reconcileStorage][用户({})存储已修正:{}GB -> {}GB]",
|
||||
userId, recordedSizeGb, actualSizeGb);
|
||||
updatedCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[reconcileStorage][处理用户存储记录失败,record({})]", record, e);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[reconcileStorage][对账完成,共检查{}个用户,修正{}个,失败{}个]",
|
||||
storageList.size(), updatedCount, errorCount);
|
||||
} catch (Exception e) {
|
||||
log.error("[reconcileStorage][对账任务执行失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -60,4 +60,30 @@ public interface MemberUserProfileMapper extends BaseMapperX<MemberUserProfileDO
|
||||
"WHERE user_id = #{userId} AND remaining_points >= #{points}")
|
||||
int updatePointsDeduct(@Param("userId") String userId, @Param("points") Integer points);
|
||||
|
||||
/**
|
||||
* 原子增加已用存储(乐观锁)
|
||||
* @param userId 用户ID
|
||||
* @param storageGb 增加的存储量(GB)
|
||||
* @return 影响行数,0表示余额不足
|
||||
*/
|
||||
@Update("UPDATE muye_member_user_profile " +
|
||||
"SET used_storage = used_storage + #{storageGb}, " +
|
||||
" remaining_storage = remaining_storage - #{storageGb}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND remaining_storage >= #{storageGb}")
|
||||
int updateStorageIncrease(@Param("userId") String userId, @Param("storageGb") String storageGb);
|
||||
|
||||
/**
|
||||
* 原子减少已用存储
|
||||
* @param userId 用户ID
|
||||
* @param storageGb 减少的存储量(GB)
|
||||
* @return 影响行数,0表示已用存储不足
|
||||
*/
|
||||
@Update("UPDATE muye_member_user_profile " +
|
||||
"SET used_storage = used_storage - #{storageGb}, " +
|
||||
" remaining_storage = remaining_storage + #{storageGb}, " +
|
||||
" update_time = NOW() " +
|
||||
"WHERE user_id = #{userId} AND used_storage >= #{storageGb}")
|
||||
int updateStorageDecrease(@Param("userId") String userId, @Param("storageGb") String storageGb);
|
||||
|
||||
}
|
||||
@@ -69,4 +69,33 @@ public interface MemberUserProfileService {
|
||||
*/
|
||||
MemberUserProfileDO createIfAbsent(Long userId);
|
||||
|
||||
// ========== 存储空间管理 ==========
|
||||
|
||||
/**
|
||||
* 校验存储空间是否足够
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
* @throws cn.iocoder.yudao.framework.common.exception.ServiceException 存储空间不足时抛出异常
|
||||
*/
|
||||
void validateStorage(String userId, long fileSizeBytes);
|
||||
|
||||
/**
|
||||
* 增加已使用存储空间
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
* @return 是否成功(余额不足返回false)
|
||||
*/
|
||||
boolean increaseUsedStorage(String userId, long fileSizeBytes);
|
||||
|
||||
/**
|
||||
* 减少已使用存储空间
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param fileSizeBytes 文件大小(字节)
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean decreaseUsedStorage(String userId, long fileSizeBytes);
|
||||
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
@@ -114,4 +115,45 @@ public class MemberUserProfileServiceImpl implements MemberUserProfileService {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ========== 存储空间管理 ==========
|
||||
|
||||
/**
|
||||
* GB转字节的常量
|
||||
*/
|
||||
private static final BigDecimal GB_TO_BYTES = new BigDecimal("1073741824");
|
||||
|
||||
@Override
|
||||
public void validateStorage(String userId, long fileSizeBytes) {
|
||||
MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId);
|
||||
if (profile == null) {
|
||||
throw exception(new ErrorCode(1004, "会员用户档案不存在"));
|
||||
}
|
||||
|
||||
// 将剩余存储空间(GB)转换为字节进行比较
|
||||
BigDecimal remainingBytes = profile.getRemainingStorage().multiply(GB_TO_BYTES);
|
||||
if (remainingBytes.compareTo(new BigDecimal(fileSizeBytes)) < 0) {
|
||||
throw exception(new ErrorCode(1005, "存储空间不足,剩余 " + profile.getRemainingStorage() + " GB"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean increaseUsedStorage(String userId, long fileSizeBytes) {
|
||||
// 将字节转换为GB(保留6位小数)
|
||||
BigDecimal storageGb = new BigDecimal(fileSizeBytes).divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP);
|
||||
String storageGbStr = storageGb.toPlainString();
|
||||
|
||||
int affectedRows = memberUserProfileMapper.updateStorageIncrease(userId, storageGbStr);
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean decreaseUsedStorage(String userId, long fileSizeBytes) {
|
||||
// 将字节转换为GB(保留6位小数)
|
||||
BigDecimal storageGb = new BigDecimal(fileSizeBytes).divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP);
|
||||
String storageGbStr = storageGb.toPlainString();
|
||||
|
||||
int affectedRows = memberUserProfileMapper.updateStorageDecrease(userId, storageGbStr);
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 用户配额 DO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("tik_user_quota")
|
||||
@KeySequence("tik_user_quota_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikUserQuotaDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 配额编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 总存储空间(字节)
|
||||
*/
|
||||
private Long totalStorage;
|
||||
/**
|
||||
* 已使用存储空间(字节)
|
||||
*/
|
||||
private Long usedStorage;
|
||||
/**
|
||||
* 总配额(积分/额度)
|
||||
*/
|
||||
private Long totalQuota;
|
||||
/**
|
||||
* 已使用配额(积分/额度)
|
||||
*/
|
||||
private Long usedQuota;
|
||||
/**
|
||||
* VIP等级
|
||||
*/
|
||||
private Integer vipLevel;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户配额 Mapper
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Mapper
|
||||
public interface TikUserQuotaMapper extends BaseMapperX<TikUserQuotaDO> {
|
||||
|
||||
default TikUserQuotaDO selectByUserId(Long userId) {
|
||||
return selectOne(TikUserQuotaDO::getUserId, userId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO;
|
||||
|
||||
/**
|
||||
* 用户配额 Service 接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface TikUserQuotaService {
|
||||
|
||||
/**
|
||||
* 初始化用户配额
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param vipLevel VIP等级
|
||||
* @return 配额编号
|
||||
*/
|
||||
Long initQuota(Long userId, Integer vipLevel);
|
||||
|
||||
/**
|
||||
* 获取用户配额
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 配额信息
|
||||
*/
|
||||
TikUserQuotaDO getQuota(Long userId);
|
||||
|
||||
/**
|
||||
* 获取或创建用户配额(如果不存在则创建)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 配额信息
|
||||
*/
|
||||
TikUserQuotaDO getOrCreateQuota(Long userId);
|
||||
|
||||
/**
|
||||
* 校验存储空间是否足够
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileSize 文件大小(字节)
|
||||
*/
|
||||
void validateStorage(Long userId, Long fileSize);
|
||||
|
||||
/**
|
||||
* 增加已使用存储空间
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileSize 文件大小(字节)
|
||||
*/
|
||||
void increaseUsedStorage(Long userId, Long fileSize);
|
||||
|
||||
/**
|
||||
* 减少已使用存储空间
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileSize 文件大小(字节)
|
||||
*/
|
||||
void decreaseUsedStorage(Long userId, Long fileSize);
|
||||
|
||||
/**
|
||||
* 更新VIP等级和配额
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param vipLevel VIP等级
|
||||
* @param totalStorage 总存储空间(字节)
|
||||
* @param totalQuota 总配额(积分/额度)
|
||||
*/
|
||||
void updateVipLevel(Long userId, Integer vipLevel, Long totalStorage, Long totalQuota);
|
||||
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO;
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.mysql.TikUserQuotaMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.QUOTA_NOT_ENOUGH;
|
||||
import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.QUOTA_NOT_EXISTS;
|
||||
|
||||
/**
|
||||
* 用户配额 Service 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class TikUserQuotaServiceImpl implements TikUserQuotaService {
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaMapper quotaMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long initQuota(Long userId, Integer vipLevel) {
|
||||
// 检查是否已存在
|
||||
TikUserQuotaDO existing = quotaMapper.selectByUserId(userId);
|
||||
if (existing != null) {
|
||||
log.info("[initQuota][用户({})配额已存在,跳过初始化]", userId);
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
// 创建配额记录(默认值)
|
||||
TikUserQuotaDO quota = TikUserQuotaDO.builder()
|
||||
.userId(userId)
|
||||
.vipLevel(vipLevel != null ? vipLevel : 0)
|
||||
.totalStorage(0L)
|
||||
.usedStorage(0L)
|
||||
.totalQuota(0L)
|
||||
.usedQuota(0L)
|
||||
.build();
|
||||
quotaMapper.insert(quota);
|
||||
log.info("[initQuota][用户({})配额初始化成功,配额编号({})]", userId, quota.getId());
|
||||
return quota.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TikUserQuotaDO getQuota(Long userId) {
|
||||
return quotaMapper.selectByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TikUserQuotaDO getOrCreateQuota(Long userId) {
|
||||
TikUserQuotaDO quota = getQuota(userId);
|
||||
if (quota == null) {
|
||||
// 如果不存在,创建默认配额
|
||||
Long quotaId = initQuota(userId, 0);
|
||||
quota = quotaMapper.selectById(quotaId);
|
||||
}
|
||||
return quota;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateStorage(Long userId, Long fileSize) {
|
||||
TikUserQuotaDO quota = getOrCreateQuota(userId);
|
||||
if (quota == null) {
|
||||
throw exception(QUOTA_NOT_EXISTS);
|
||||
}
|
||||
|
||||
long remainingStorage = quota.getTotalStorage() - quota.getUsedStorage();
|
||||
if (remainingStorage < fileSize) {
|
||||
throw exception(QUOTA_NOT_ENOUGH);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void increaseUsedStorage(Long userId, Long fileSize) {
|
||||
TikUserQuotaDO quota = getOrCreateQuota(userId);
|
||||
quota.setUsedStorage(quota.getUsedStorage() + fileSize);
|
||||
quotaMapper.updateById(quota);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void decreaseUsedStorage(Long userId, Long fileSize) {
|
||||
TikUserQuotaDO quota = getQuota(userId);
|
||||
if (quota == null) {
|
||||
log.warn("[decreaseUsedStorage][用户({})配额不存在,跳过]", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
long newUsedStorage = Math.max(0, quota.getUsedStorage() - fileSize);
|
||||
quota.setUsedStorage(newUsedStorage);
|
||||
quotaMapper.updateById(quota);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateVipLevel(Long userId, Integer vipLevel, Long totalStorage, Long totalQuota) {
|
||||
TikUserQuotaDO quota = getOrCreateQuota(userId);
|
||||
quota.setVipLevel(vipLevel);
|
||||
if (totalStorage != null) {
|
||||
quota.setTotalStorage(totalStorage);
|
||||
}
|
||||
if (totalQuota != null) {
|
||||
quota.setTotalQuota(totalQuota);
|
||||
}
|
||||
quotaMapper.updateById(quota);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户 App - 用户配额 Response VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 用户配额 Response VO")
|
||||
@Data
|
||||
public class AppTikUserQuotaRespVO {
|
||||
|
||||
@Schema(description = "配额编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "总存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1073741824")
|
||||
private Long totalStorage;
|
||||
|
||||
@Schema(description = "已使用存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024000")
|
||||
private Long usedStorage;
|
||||
|
||||
@Schema(description = "剩余存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1072717824")
|
||||
private Long remainingStorage;
|
||||
|
||||
@Schema(description = "总配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000")
|
||||
private Long totalQuota;
|
||||
|
||||
@Schema(description = "已使用配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
|
||||
private Long usedQuota;
|
||||
|
||||
@Schema(description = "剩余配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "900")
|
||||
private Long remainingQuota;
|
||||
|
||||
@Schema(description = "VIP等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
private Integer vipLevel;
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user