Merge remote-tracking branch 'origin/seedance' into seedance
This commit is contained in:
commit
c04c1e572b
|
|
@ -54,16 +54,22 @@
|
|||
<div v-else-if="isReference" class="vg-reference-panel">
|
||||
<div class="vg-compose-left-head">
|
||||
<div class="vg-compose-left-title">
|
||||
参考图({{ mediaList.length }}/{{ maxMediaCount }})
|
||||
参考素材({{ mediaList.length }}/{{ maxMediaCount }})
|
||||
</div>
|
||||
</div>
|
||||
<div class="vg-asset-controls">
|
||||
<input v-model.trim="assetGroupId" class="vg-asset-group-input" placeholder="请输入 GroupId" />
|
||||
<mf-button class="vg-compose-left-upload" size="small" @click="loadAssetsByGroup" :loading="assetLoading">
|
||||
查询资产
|
||||
</mf-button>
|
||||
<mf-button class="vg-compose-left-upload" size="small" type="primary" @click="openFilePicker">
|
||||
添加图片
|
||||
上传资产
|
||||
</mf-button>
|
||||
</div>
|
||||
|
||||
<div v-if="mediaList.length === 0" class="vg-compose-empty" @click="openFilePicker">
|
||||
<div class="vg-compose-empty-icon" aria-hidden="true">+</div>
|
||||
<div class="vg-compose-empty-text">点击添加参考图</div>
|
||||
<div class="vg-compose-empty-text">点击上传资产或按 GroupId 查询资产</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="vg-compose-media-scroll">
|
||||
|
|
@ -73,7 +79,17 @@
|
|||
class="vg-compose-media-item"
|
||||
:title="item.name || ''">
|
||||
<div class="vg-compose-media-preview">
|
||||
<img :src="item.url" alt="" />
|
||||
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" />
|
||||
<video
|
||||
v-else-if="item.mediaType === 'video'"
|
||||
:src="item.url"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata" />
|
||||
<div v-else-if="item.mediaType === 'audio'" class="vg-audio-tile">
|
||||
<span class="vg-audio-tile-icon">♪</span>
|
||||
<span class="vg-audio-tile-text">音频</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -111,13 +127,21 @@
|
|||
@click="onEditorClick"></div>
|
||||
<div v-if="mentionVisible" class="vg-mention-panel">
|
||||
<div
|
||||
v-for="item in mentionCandidates"
|
||||
v-for="(item, idx) in mentionCandidates"
|
||||
:key="item.key"
|
||||
class="vg-mention-item"
|
||||
:class="['vg-mention-item', { active: idx === mentionActiveIndex }]"
|
||||
@mousedown.prevent="selectMentionItem(item)">
|
||||
<img :src="item.url" alt="" class="vg-mention-thumb" />
|
||||
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" class="vg-mention-thumb" />
|
||||
<video
|
||||
v-else-if="item.mediaType === 'video'"
|
||||
:src="item.url"
|
||||
class="vg-mention-thumb"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata" />
|
||||
<span v-else class="vg-mention-audio">{{ item.name || '音频' }}</span>
|
||||
</div>
|
||||
<div v-if="mentionCandidates.length === 0" class="vg-mention-empty">暂无可引用参考图</div>
|
||||
<div v-if="mentionCandidates.length === 0" class="vg-mention-empty">暂无可引用素材</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -136,12 +160,12 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||||
|
||||
/** 图生参考:不同参考图最多 4 张(图1–图4),同一 URL 可多次 @ */
|
||||
const MAX_REFERENCE_UNIQUE = 4
|
||||
/** 参考素材:同类最多不同素材条数(同一素材可多次 @),与 maxMediaCount 对齐 */
|
||||
const MAX_REFERENCE_UNIQUE_KIND = 9
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
@ -174,11 +198,15 @@ const emit = defineEmits(['update:modelValue', 'update:mediaList'])
|
|||
|
||||
const fileInputRef = ref(null)
|
||||
const editorRef = ref(null)
|
||||
const { proxy } = getCurrentInstance() || {}
|
||||
const localPrompt = ref(props.modelValue || '')
|
||||
const internalMediaList = ref(Array.isArray(props.mediaList) ? [...props.mediaList] : [])
|
||||
const currentUploadIndex = ref(-1) // for first/last frame mode
|
||||
const savedSelectionRange = ref(null)
|
||||
const mentionVisible = ref(false)
|
||||
const mentionActiveIndex = ref(-1)
|
||||
const assetGroupId = ref('')
|
||||
const assetLoading = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
|
@ -221,10 +249,19 @@ onMounted(() => {
|
|||
const mediaList = computed(() => internalMediaList.value)
|
||||
const mentionCandidates = computed(() =>
|
||||
(mediaList.value || [])
|
||||
.filter((i) => i?.mediaType === 'image' && i?.url)
|
||||
.filter((i) => {
|
||||
const u = String(i?.url || '').trim()
|
||||
const t = i?.mediaType
|
||||
if (!['image', 'video', 'audio'].includes(t) || i?.isUploading) return false
|
||||
if (!i?.assetId && !/^https?:\/\//i.test(u)) return false
|
||||
return true
|
||||
})
|
||||
.map((i, idx) => ({
|
||||
key: i.id || i.url || String(idx),
|
||||
url: i.url
|
||||
url: i.url,
|
||||
assetId: i.assetId || '',
|
||||
mediaType: i.mediaType || 'image',
|
||||
name: i.name || ''
|
||||
}))
|
||||
)
|
||||
|
||||
|
|
@ -255,9 +292,16 @@ const openFilePickerFor = (index) => {
|
|||
|
||||
const acceptAttr = computed(() => {
|
||||
const types = new Set(props.allowedMediaTypes || [])
|
||||
if (types.has('image') && types.has('video')) return 'image/*,video/*'
|
||||
if (types.has('image')) return 'image/*'
|
||||
if (types.has('video')) return 'video/*'
|
||||
const hasI = types.has('image')
|
||||
const hasV = types.has('video')
|
||||
const hasA = types.has('audio')
|
||||
if (hasI && hasV && hasA) return 'image/*,video/*,audio/*'
|
||||
if (hasI && hasV) return 'image/*,video/*'
|
||||
if (hasI && hasA) return 'image/*,audio/*'
|
||||
if (hasV && hasA) return 'video/*,audio/*'
|
||||
if (hasI) return 'image/*'
|
||||
if (hasV) return 'video/*'
|
||||
if (hasA) return 'audio/*'
|
||||
return 'image/*,video/*'
|
||||
})
|
||||
|
||||
|
|
@ -265,9 +309,11 @@ const detectMediaType = (file) => {
|
|||
const mime = (file?.type || '').toLowerCase()
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
const lowerName = (file?.name || '').toLowerCase()
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv|ogg)$/.test(lowerName)) return 'video'
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|svg|image)$/.test(lowerName)) return 'image'
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv|video)$/.test(lowerName)) return 'video'
|
||||
if (/\.(mp3|wav|m4a|aac|flac|wma|audio)$/.test(lowerName)) return 'audio'
|
||||
return 'image'
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +332,7 @@ const removeItem = (item) => {
|
|||
|
||||
const saveReferenceToStorage = (list) => {
|
||||
try {
|
||||
localStorage.setItem('video_reference_images', JSON.stringify(list))
|
||||
localStorage.setItem('video_reference_media', JSON.stringify(list))
|
||||
} catch (e) {
|
||||
console.warn('Failed to save reference images to localStorage')
|
||||
}
|
||||
|
|
@ -294,7 +340,7 @@ const saveReferenceToStorage = (list) => {
|
|||
|
||||
const loadReferenceFromStorage = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('video_reference_images')
|
||||
const saved = localStorage.getItem('video_reference_media') || localStorage.getItem('video_reference_images')
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
|
|
@ -309,12 +355,82 @@ const clearAll = () => {
|
|||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
const mergeAssetsToMediaList = (assets) => {
|
||||
const existing = new Set((mediaList.value || []).map((x) => x.assetId || x.url))
|
||||
const next = [...mediaList.value]
|
||||
for (const item of assets) {
|
||||
const key = item.assetId || item.url
|
||||
if (!key || existing.has(key)) continue
|
||||
next.push(item)
|
||||
existing.add(key)
|
||||
if (next.length >= props.maxMediaCount) break
|
||||
}
|
||||
setMediaList(next.slice(0, props.maxMediaCount))
|
||||
}
|
||||
|
||||
const loadAssetsByGroup = async () => {
|
||||
const gid = String(assetGroupId.value || '').trim()
|
||||
if (!gid) {
|
||||
Message.warning('请先填写 GroupId 再查询')
|
||||
return
|
||||
}
|
||||
if (!proxy?.$axios) {
|
||||
Message.error('查询失败:axios 未就绪')
|
||||
return
|
||||
}
|
||||
assetLoading.value = true
|
||||
try {
|
||||
const res = await proxy.$axios({
|
||||
url: 'api/portal/asset/listAssets',
|
||||
method: 'POST',
|
||||
data: {
|
||||
Filter: {
|
||||
GroupIds: [gid],
|
||||
GroupType: 'AIGC',
|
||||
Statuses: ['Active']
|
||||
},
|
||||
PageNumber: 1,
|
||||
PageSize: 100,
|
||||
SortBy: 'CreateTime',
|
||||
SortOrder: 'Desc'
|
||||
}
|
||||
})
|
||||
const rows = Array.isArray(res?.data?.Items) ? res.data.Items : []
|
||||
const assets = rows
|
||||
.map((row) => {
|
||||
const at = String(row?.AssetType || row?.assetType || 'Image').toLowerCase()
|
||||
let mediaType = 'image'
|
||||
if (at.includes('video')) mediaType = 'video'
|
||||
else if (at.includes('audio')) mediaType = 'audio'
|
||||
return {
|
||||
id: row?.Id || row?.id || `asset_${Math.random().toString(16).slice(2)}`,
|
||||
assetId: row?.Id || row?.id || '',
|
||||
url: row?.URL || row?.url || '',
|
||||
mediaType,
|
||||
name: row?.Name || row?.name || ''
|
||||
}
|
||||
})
|
||||
.filter((x) => x.assetId && /^https?:\/\//i.test(String(x.url || '').trim()))
|
||||
mergeAssetsToMediaList(assets)
|
||||
Message.success(`已加载 ${assets.length} 条可用素材`)
|
||||
} catch (err) {
|
||||
Message.error(err?.message || '查询素材失败')
|
||||
} finally {
|
||||
assetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectFiles = async (event) => {
|
||||
const input = event.target
|
||||
const files = Array.from(input.files || [])
|
||||
if (!files.length) return
|
||||
|
||||
const isReferenceMode = isReference.value
|
||||
if (isReferenceMode && !String(assetGroupId.value || '').trim()) {
|
||||
Message.warning('参考图模式请先填写 GroupId')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
let targetList = [...mediaList.value]
|
||||
|
||||
// 首尾帧模式特殊处理索引
|
||||
|
|
@ -385,15 +501,39 @@ const clearAll = () => {
|
|||
})
|
||||
const url = extractUploadUrlFromResponse(res)
|
||||
if (!url) throw new Error(res?.msg || '未返回文件地址')
|
||||
let assetId = entry.assetId || ''
|
||||
if (isReferenceMode) {
|
||||
const gid = String(assetGroupId.value || '').trim()
|
||||
if (!gid) throw new Error('请先填写 GroupId')
|
||||
if (!proxy?.$axios) throw new Error('上传资产失败:axios 未就绪')
|
||||
const mt = entry.mediaType || 'image'
|
||||
const assetType =
|
||||
mt === 'video' ? 'Video' : mt === 'audio' ? 'Audio' : 'Image'
|
||||
const createRes = await proxy.$axios({
|
||||
url: 'api/portal/asset/createAsset',
|
||||
method: 'POST',
|
||||
data: {
|
||||
GroupId: gid,
|
||||
URL: url,
|
||||
Name: entry?.name || '',
|
||||
AssetType: assetType
|
||||
}
|
||||
})
|
||||
assetId = createRes?.data?.Id || createRes?.data?.id || ''
|
||||
if (!assetId) throw new Error(createRes?.msg || '创建素材失败:未返回资产ID')
|
||||
}
|
||||
|
||||
const localPreview = entry.url
|
||||
setMediaList(
|
||||
mediaList.value.map((x) =>
|
||||
normalizeItemKey(x) === normalizeItemKey(entry)
|
||||
? { ...x, url, isUploading: false }
|
||||
? { ...x, url, assetId, isUploading: false }
|
||||
: x
|
||||
)
|
||||
)
|
||||
if (isReferenceMode) {
|
||||
Message.success('已上传完成')
|
||||
}
|
||||
|
||||
try {
|
||||
URL.revokeObjectURL(localPreview)
|
||||
|
|
@ -472,7 +612,7 @@ const isBlockElement = (n) =>
|
|||
n.nodeType === Node.ELEMENT_NODE &&
|
||||
['DIV', 'P', 'LI'].includes(n.tagName)
|
||||
|
||||
/** 与编辑器 DOM 顺序一致:描述与 [图n] 在文中的位置即用户输入/插入的位置 */
|
||||
/** 与编辑器 DOM 顺序一致:描述与 [图片n]/[视频n]/[音频n] 在文中的位置即用户输入/插入的位置 */
|
||||
const getEditorPlainText = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return ''
|
||||
|
|
@ -504,97 +644,139 @@ const getEditorPlainText = () => {
|
|||
return out.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
const getUniqueRefUrlsInDoc = () => {
|
||||
const refKeyForEl = (el) => {
|
||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
|
||||
const url = (el.getAttribute('data-reference-url') || '').trim()
|
||||
return `${kind}:${assetId || url}`
|
||||
}
|
||||
|
||||
const getUniqueRefKeysInDocForKind = (kind) => {
|
||||
const s = new Set()
|
||||
editorRef.value?.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
||||
const u = el.getAttribute('data-reference-url')
|
||||
if (u) s.add(u)
|
||||
if ((el.getAttribute('data-reference-kind') || 'image') !== kind) return
|
||||
const k = refKeyForEl(el)
|
||||
if (k && k !== `${kind}:`) s.add(k)
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
/** 按文档顺序为不同 URL 分配 图1–图4,并同步 data-token / 展示 */
|
||||
/** 按文档顺序:同类素材独立编号 [图片n][视频n][音频n],同一素材多次 @ 共用同一序号 */
|
||||
const renumberAllReferenceMentions = () => {
|
||||
if (!editorRef.value || !isReference.value) return
|
||||
const refs = Array.from(editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]'))
|
||||
const urlToNo = new Map()
|
||||
let next = 1
|
||||
const imgNo = new Map()
|
||||
const vidNo = new Map()
|
||||
const audNo = new Map()
|
||||
let imgNext = 1
|
||||
let vidNext = 1
|
||||
let audNext = 1
|
||||
let droppedExtra = false
|
||||
for (const el of refs) {
|
||||
const u = el.getAttribute('data-reference-url') || ''
|
||||
if (!u) {
|
||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||
const keyCore = (el.getAttribute('data-reference-asset-id') || el.getAttribute('data-reference-url') || '').trim()
|
||||
if (!keyCore) {
|
||||
el.remove()
|
||||
continue
|
||||
}
|
||||
if (!urlToNo.has(u)) {
|
||||
if (next > MAX_REFERENCE_UNIQUE) {
|
||||
let map
|
||||
let nextRef
|
||||
let maxU
|
||||
if (kind === 'video') {
|
||||
map = vidNo
|
||||
nextRef = () => vidNext++
|
||||
maxU = MAX_REFERENCE_UNIQUE_KIND
|
||||
} else if (kind === 'audio') {
|
||||
map = audNo
|
||||
nextRef = () => audNext++
|
||||
maxU = MAX_REFERENCE_UNIQUE_KIND
|
||||
} else {
|
||||
map = imgNo
|
||||
nextRef = () => imgNext++
|
||||
maxU = MAX_REFERENCE_UNIQUE_KIND
|
||||
}
|
||||
const fullKey = `${kind}:${keyCore}`
|
||||
if (!map.has(fullKey)) {
|
||||
if (map.size >= maxU) {
|
||||
el.remove()
|
||||
droppedExtra = true
|
||||
continue
|
||||
}
|
||||
urlToNo.set(u, next++)
|
||||
map.set(fullKey, nextRef())
|
||||
}
|
||||
const n = urlToNo.get(u)
|
||||
const token = `[图${n}]`
|
||||
const n = map.get(fullKey)
|
||||
const token =
|
||||
kind === 'video' ? `[视频${n}]` : kind === 'audio' ? `[音频${n}]` : `[图片${n}]`
|
||||
el.setAttribute('data-token', token)
|
||||
const imgEl = el.querySelector('.vg-inline-ref-image')
|
||||
if (imgEl) imgEl.alt = token
|
||||
const prev = el.querySelector('.vg-inline-ref-image, .vg-inline-ref-video, .vg-inline-ref-audio')
|
||||
if (prev) {
|
||||
if (prev.tagName === 'IMG' || prev.tagName === 'VIDEO') prev.setAttribute('alt', token)
|
||||
}
|
||||
}
|
||||
if (droppedExtra) {
|
||||
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,已移除多余引用`)
|
||||
Message.warning(`同类参考素材最多 ${MAX_REFERENCE_UNIQUE_KIND} 种,已移除多余引用`)
|
||||
}
|
||||
}
|
||||
|
||||
const reconcileReferenceMentions = (nextList) => {
|
||||
if (!editorRef.value) return
|
||||
const allowed = new Set(
|
||||
(nextList || []).filter((x) => x?.mediaType === 'image' && x?.url).map((x) => x.url)
|
||||
(nextList || [])
|
||||
.filter((x) => ['image', 'video', 'audio'].includes(x?.mediaType) && (x?.assetId || x?.url))
|
||||
.map((x) => `${x.mediaType}:${x.assetId || x.url}`)
|
||||
)
|
||||
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
||||
const u = el.getAttribute('data-reference-url') || ''
|
||||
if (!allowed.has(u)) el.remove()
|
||||
const k = refKeyForEl(el)
|
||||
if (!allowed.has(k)) el.remove()
|
||||
})
|
||||
renumberAllReferenceMentions()
|
||||
setPrompt(getEditorPlainText())
|
||||
}
|
||||
|
||||
/** 文档顺序下首次出现的参考图 URL(对应 图1、图2…) */
|
||||
const collectReferenceUrlsInDocOrder = () => {
|
||||
/** 文档顺序下去重后的参考素材(用于 content),同类内顺序即 [图片n] 等序号依据 */
|
||||
const collectReferenceContentInOrder = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return []
|
||||
const urls = []
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
const walk = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
const el = node
|
||||
if (el.dataset?.mentionReference === '1') {
|
||||
const url = el.getAttribute('data-reference-url') || ''
|
||||
if (url && !seen.has(url)) {
|
||||
seen.add(url)
|
||||
urls.push(url)
|
||||
}
|
||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
|
||||
const url = (el.getAttribute('data-reference-url') || '').trim()
|
||||
const key = `${kind}:${assetId || url}`
|
||||
if (!key || key === `${kind}:` || seen.has(key)) return
|
||||
seen.add(key)
|
||||
out.push({ kind, assetId, url })
|
||||
return
|
||||
}
|
||||
Array.from(el.childNodes).forEach(walk)
|
||||
}
|
||||
Array.from(editor.childNodes).forEach(walk)
|
||||
return urls
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考图模式提交用:第 1 条必须是 text(整段文案,仅含 [图n],不含 URL);
|
||||
* 其后按 图1→图n 顺序各一条 image_url + reference_image。
|
||||
* 参考图模式提交用:首条 type=text(含 [图片n]/[视频n]/[音频n],不含 Asset ID);
|
||||
* 其后为各类 reference_*,url 可为 asset:// 或 http(s)。
|
||||
*/
|
||||
const getImageReferenceContentItems = () => {
|
||||
const text = getEditorPlainText()
|
||||
const first = { type: 'text', text: text || '' }
|
||||
const urls = collectReferenceUrlsInDocOrder()
|
||||
const rest = urls.map((url) => ({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: 'reference_image'
|
||||
}))
|
||||
const refs = collectReferenceContentInOrder()
|
||||
const rest = refs.map((ref) => {
|
||||
const urlStr = ref.assetId ? `asset://${ref.assetId}` : ref.url
|
||||
if (ref.kind === 'video') {
|
||||
return { type: 'video_url', video_url: { url: urlStr }, role: 'reference_video' }
|
||||
}
|
||||
if (ref.kind === 'audio') {
|
||||
return { type: 'audio_url', audio_url: { url: urlStr }, role: 'reference_audio' }
|
||||
}
|
||||
return { type: 'image_url', image_url: { url: urlStr }, role: 'reference_image' }
|
||||
})
|
||||
return [first, ...rest]
|
||||
}
|
||||
|
||||
|
|
@ -603,6 +785,12 @@ const onEditorInput = () => {
|
|||
setPrompt(getEditorPlainText())
|
||||
saveSelection()
|
||||
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
|
||||
if (mentionVisible.value) {
|
||||
const max = mentionCandidates.value.length - 1
|
||||
mentionActiveIndex.value = max >= 0 ? Math.min(Math.max(mentionActiveIndex.value, 0), max) : -1
|
||||
} else {
|
||||
mentionActiveIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const findClosestMentionInEditor = (el) => {
|
||||
|
|
@ -636,9 +824,41 @@ const onEditorClick = (e) => {
|
|||
}
|
||||
saveSelection()
|
||||
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
|
||||
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
|
||||
}
|
||||
|
||||
const onEditorKeydown = (e) => {
|
||||
if (mentionVisible.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const len = mentionCandidates.value.length
|
||||
if (len > 0) {
|
||||
if (mentionActiveIndex.value < 0) {
|
||||
mentionActiveIndex.value = 0
|
||||
} else {
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1
|
||||
mentionActiveIndex.value = (mentionActiveIndex.value + delta + len) % len
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const idx = mentionActiveIndex.value >= 0 ? mentionActiveIndex.value : 0
|
||||
const picked = mentionCandidates.value[idx]
|
||||
if (picked) {
|
||||
e.preventDefault()
|
||||
selectMentionItem(picked)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
mentionVisible.value = false
|
||||
mentionActiveIndex.value = -1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReference.value || !editorRef.value) return
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.rangeCount === 0) return
|
||||
|
|
@ -731,16 +951,27 @@ const onEditorKeyup = (e) => {
|
|||
saveSelection()
|
||||
if (e.key === 'Escape') {
|
||||
mentionVisible.value = false
|
||||
mentionActiveIndex.value = -1
|
||||
return
|
||||
}
|
||||
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
|
||||
if (mentionVisible.value && e.key === '@' && mentionCandidates.value.length === 0) {
|
||||
Message.warning('请等待上传完成后再引用')
|
||||
mentionVisible.value = false
|
||||
mentionActiveIndex.value = -1
|
||||
return
|
||||
}
|
||||
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
|
||||
}
|
||||
|
||||
const selectMentionItem = (item) => {
|
||||
if (!item?.url || !editorRef.value) return
|
||||
const urls = getUniqueRefUrlsInDoc()
|
||||
if (!urls.has(item.url) && urls.size >= MAX_REFERENCE_UNIQUE) {
|
||||
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,无法再插入新图`)
|
||||
const kind = item.mediaType || 'image'
|
||||
const key = `${kind}:${(item.assetId || '').trim() || String(item.url || '').trim()}`
|
||||
const keys = getUniqueRefKeysInDocForKind(kind)
|
||||
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
|
||||
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'
|
||||
Message.warning(`同类参考${label}最多 ${MAX_REFERENCE_UNIQUE_KIND} 种,无法再插入新素材`)
|
||||
mentionVisible.value = false
|
||||
return
|
||||
}
|
||||
|
|
@ -754,25 +985,42 @@ const selectMentionItem = (item) => {
|
|||
const range = selection.getRangeAt(0)
|
||||
if (!editorRef.value.contains(range.commonAncestorContainer)) return
|
||||
|
||||
const token = '[图?]'
|
||||
const token = '[?]'
|
||||
const holder = document.createElement('span')
|
||||
holder.className = 'vg-inline-ref'
|
||||
holder.setAttribute('data-mention-reference', '1')
|
||||
holder.setAttribute('data-token', token)
|
||||
holder.setAttribute('data-reference-url', item.url)
|
||||
holder.setAttribute('data-reference-asset-id', item.assetId || '')
|
||||
holder.setAttribute('data-reference-kind', kind)
|
||||
holder.setAttribute('contenteditable', 'false')
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = item.url
|
||||
img.alt = ''
|
||||
img.setAttribute('draggable', 'false')
|
||||
img.setAttribute('contenteditable', 'false')
|
||||
img.className = 'vg-inline-ref-image'
|
||||
|
||||
holder.appendChild(img)
|
||||
if (kind === 'video') {
|
||||
const v = document.createElement('video')
|
||||
v.src = item.url
|
||||
v.className = 'vg-inline-ref-video'
|
||||
v.muted = true
|
||||
v.playsInline = true
|
||||
v.setAttribute('preload', 'metadata')
|
||||
v.setAttribute('draggable', 'false')
|
||||
v.setAttribute('contenteditable', 'false')
|
||||
holder.appendChild(v)
|
||||
} else if (kind === 'audio') {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'vg-inline-ref-audio'
|
||||
span.textContent = item.name ? `♪ ${item.name}` : '♪ 音频'
|
||||
holder.appendChild(span)
|
||||
} else {
|
||||
const img = document.createElement('img')
|
||||
img.src = item.url
|
||||
img.alt = ''
|
||||
img.setAttribute('draggable', 'false')
|
||||
img.setAttribute('contenteditable', 'false')
|
||||
img.className = 'vg-inline-ref-image'
|
||||
holder.appendChild(img)
|
||||
}
|
||||
|
||||
range.insertNode(holder)
|
||||
// 不在引用后自动加空格,保证导出 text 为 「描述[图1]描述[图2]…」,占位与描述紧挨在用户输入的位置
|
||||
range.setStartAfter(holder)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
|
|
@ -780,6 +1028,7 @@ const selectMentionItem = (item) => {
|
|||
|
||||
saveSelection()
|
||||
mentionVisible.value = false
|
||||
mentionActiveIndex.value = -1
|
||||
renumberAllReferenceMentions()
|
||||
setPrompt(getEditorPlainText())
|
||||
}
|
||||
|
|
@ -791,6 +1040,7 @@ defineExpose({
|
|||
setPrompt('')
|
||||
if (editorRef.value) editorRef.value.innerHTML = ''
|
||||
mentionVisible.value = false
|
||||
mentionActiveIndex.value = -1
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -924,6 +1174,29 @@ defineExpose({
|
|||
color: #a8f4f9;
|
||||
}
|
||||
|
||||
.vg-asset-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vg-asset-group-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 0 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vg-asset-group-input:focus {
|
||||
border-color: rgba(0, 202, 224, 0.55);
|
||||
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.12);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -1136,6 +1409,55 @@ defineExpose({
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vg-rich-editor :deep(.vg-inline-ref-video) {
|
||||
display: block;
|
||||
max-width: 120px;
|
||||
max-height: 72px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vg-rich-editor :deep(.vg-inline-ref-audio) {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(0, 202, 224, 0.12);
|
||||
border-radius: 8px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vg-audio-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vg-audio-tile-icon {
|
||||
font-size: 22px;
|
||||
color: rgba(0, 202, 224, 0.85);
|
||||
}
|
||||
|
||||
.vg-mention-audio {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.vg-mention-panel {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
|
|
@ -1163,6 +1485,10 @@ defineExpose({
|
|||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.vg-mention-item.active {
|
||||
background: rgba(0, 202, 224, 0.18);
|
||||
}
|
||||
|
||||
.vg-mention-thumb {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'إنشاء فيديو',
|
||||
recharge: 'شحن سريع',
|
||||
help: 'مركز المساعدة',
|
||||
moneyInvite: 'دعوة مكافآت'
|
||||
moneyInvite: 'دعوة مكافآت',
|
||||
assetGroupManage: 'إدارة مجموعات الأصول',
|
||||
assetManage: 'إدارة الأصول'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,7 @@ export default {
|
|||
videoGen: 'Video Generation',
|
||||
recharge: 'Quick Recharge',
|
||||
help: 'Help Center',
|
||||
moneyInvite: 'Reward Invitation'
|
||||
moneyInvite: 'Reward Invitation',
|
||||
assetGroupManage: 'Asset Group Manage',
|
||||
assetManage: 'Asset Manage'
|
||||
}
|
||||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Generar video',
|
||||
recharge: 'Recarga rápida',
|
||||
help: 'Centro de ayuda',
|
||||
moneyInvite: 'Invitación con recompensa'
|
||||
moneyInvite: 'Invitación con recompensa',
|
||||
assetGroupManage: 'Gestión de grupos de activos',
|
||||
assetManage: 'Gestión de activos'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Générer une vidéo',
|
||||
recharge: 'Recharge rapide',
|
||||
help: 'Centre d\'aide',
|
||||
moneyInvite: 'Invitation avec récompense'
|
||||
moneyInvite: 'Invitation avec récompense',
|
||||
assetGroupManage: 'Gestion des groupes d\'actifs',
|
||||
assetManage: 'Gestion des actifs'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'वीडियो जनरेट करें',
|
||||
recharge: 'त्वरित रिचार्ज',
|
||||
help: 'सहायता केंद्र',
|
||||
moneyInvite: 'इनाम निमंत्रण'
|
||||
moneyInvite: 'इनाम निमंत्रण',
|
||||
assetGroupManage: 'एसेट समूह प्रबंधन',
|
||||
assetManage: 'एसेट प्रबंधन'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Gerar vídeo',
|
||||
recharge: 'Recarga rápida',
|
||||
help: 'Central de ajuda',
|
||||
moneyInvite: 'Convite com recompensa'
|
||||
moneyInvite: 'Convite com recompensa',
|
||||
assetGroupManage: 'Gerenciamento de grupos de ativos',
|
||||
assetManage: 'Gerenciamento de ativos'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Создать видео',
|
||||
recharge: 'Быстрая пополнение',
|
||||
help: 'Центр помощи',
|
||||
moneyInvite: 'Приглашение с наградой'
|
||||
moneyInvite: 'Приглашение с наградой',
|
||||
assetGroupManage: 'Управление группами ресурсов',
|
||||
assetManage: 'Управление ресурсами'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,7 @@ export default {
|
|||
videoGen: '視頻生成',
|
||||
recharge: '快速充值',
|
||||
help: '幫助中心',
|
||||
moneyInvite: '有獎邀請'
|
||||
moneyInvite: '有獎邀請',
|
||||
assetGroupManage: '資源組管理',
|
||||
assetManage: '素材管理'
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ import { constantRoutes } from '@/router/index'
|
|||
import { generateTitle, generateLang } from '@/utils/i18n'
|
||||
|
||||
/** 左侧导航仅显示这些路由(name 与 router/index.js 一致) */
|
||||
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen']
|
||||
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'asset-group-manage', 'asset-manage']
|
||||
|
||||
defineProps({
|
||||
collapsed: Boolean
|
||||
|
|
|
|||
|
|
@ -129,6 +129,26 @@ export const constantRoutes = [{
|
|||
permission: "pass",
|
||||
icon: 'btn_video'
|
||||
}
|
||||
}, {
|
||||
path: 'asset-group-manage',
|
||||
name: 'asset-group-manage',
|
||||
component: () => import('@/views/AssetGroupManage.vue'),
|
||||
meta: {
|
||||
title: 'assetGroupManage',
|
||||
menuItem: true,
|
||||
permission: "pass",
|
||||
icon: 'btn_video'
|
||||
}
|
||||
}, {
|
||||
path: 'asset-manage',
|
||||
name: 'asset-manage',
|
||||
component: () => import('@/views/AssetManage.vue'),
|
||||
meta: {
|
||||
title: 'assetManage',
|
||||
menuItem: true,
|
||||
permission: "pass",
|
||||
icon: 'btn_video'
|
||||
}
|
||||
}, {
|
||||
path: 'recharge',
|
||||
name: 'recharge',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
<template>
|
||||
<div class="asset-group-page">
|
||||
<section class="ag-panel">
|
||||
<h3 class="ag-title">新增资源组</h3>
|
||||
<div class="ag-form">
|
||||
<div class="ag-field">
|
||||
<label>名称</label>
|
||||
<a-input v-model="createForm.name" placeholder="请输入资源组名称(<=64字符)" />
|
||||
</div>
|
||||
<div class="ag-field">
|
||||
<label>描述</label>
|
||||
<a-textarea
|
||||
v-model="createForm.description"
|
||||
:max-length="300"
|
||||
show-word-limit
|
||||
placeholder="请输入描述(<=300字符)" />
|
||||
</div>
|
||||
<div class="ag-field">
|
||||
<label>GroupType</label>
|
||||
<a-select v-model="createForm.groupType" :disabled="true">
|
||||
<a-option value="AIGC">AIGC</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="ag-actions">
|
||||
<a-button type="primary" :loading="createLoading" @click="createGroup">新增</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ag-panel">
|
||||
<h3 class="ag-title">查询资源组</h3>
|
||||
<div class="ag-filter">
|
||||
<div class="ag-field">
|
||||
<label>名称</label>
|
||||
<a-input v-model="filters.name" placeholder="按名称过滤" />
|
||||
</div>
|
||||
<div class="ag-field">
|
||||
<label>GroupIds</label>
|
||||
<a-input v-model="filters.groupIdsText" placeholder="多个ID用英文逗号分隔" />
|
||||
</div>
|
||||
<div class="ag-field">
|
||||
<label>GroupType</label>
|
||||
<a-select v-model="filters.groupType">
|
||||
<a-option value="AIGC">AIGC</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="ag-field">
|
||||
<label>SortBy</label>
|
||||
<a-select v-model="filters.sortBy">
|
||||
<a-option value="CreateTime">CreateTime</a-option>
|
||||
<a-option value="UpdateTime">UpdateTime</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="ag-field">
|
||||
<label>SortOrder</label>
|
||||
<a-select v-model="filters.sortOrder">
|
||||
<a-option value="Desc">Desc</a-option>
|
||||
<a-option value="Asc">Asc</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="ag-actions">
|
||||
<a-button type="primary" :loading="listLoading" @click="search(1)">查询</a-button>
|
||||
<a-button @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading">
|
||||
<div class="ag-total">总数:{{ totalCount }}</div>
|
||||
<div class="ag-table-wrap">
|
||||
<table class="ag-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>GroupType</th>
|
||||
<th>ProjectName</th>
|
||||
<th>CreateTime</th>
|
||||
<th>UpdateTime</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.Id || item.id">
|
||||
<td>{{ item.Id || item.id }}</td>
|
||||
<td>{{ item.Name || item.name }}</td>
|
||||
<td>{{ item.Description || item.description || '-' }}</td>
|
||||
<td>{{ item.GroupType || item.groupType || '-' }}</td>
|
||||
<td>{{ item.ProjectName || item.projectName || '-' }}</td>
|
||||
<td>{{ item.CreateTime || item.createTime || '-' }}</td>
|
||||
<td>{{ item.UpdateTime || item.updateTime || '-' }}</td>
|
||||
<td>
|
||||
<a-button
|
||||
size="mini"
|
||||
type="outline"
|
||||
:loading="detailLoadingId === (item.Id || item.id)"
|
||||
@click="getDetail(item)">
|
||||
详情
|
||||
</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!items.length">
|
||||
<td colspan="8" class="ag-empty">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ag-pagination">
|
||||
<a-pagination
|
||||
:total="totalCount"
|
||||
:current="filters.pageNumber"
|
||||
:page-size="filters.pageSize"
|
||||
show-total
|
||||
show-jumper
|
||||
@change="search"
|
||||
@page-size-change="onPageSizeChange" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
|
||||
<a-modal v-model:visible="detailVisible" title="资源组详情" :footer="false" width="680px">
|
||||
<pre class="ag-detail">{{ prettyDetail }}</pre>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AssetGroupManage',
|
||||
data() {
|
||||
return {
|
||||
createLoading: false,
|
||||
listLoading: false,
|
||||
detailLoadingId: '',
|
||||
detailVisible: false,
|
||||
detailData: null,
|
||||
createForm: {
|
||||
name: '',
|
||||
description: '',
|
||||
groupType: 'AIGC'
|
||||
},
|
||||
filters: {
|
||||
name: '',
|
||||
groupIdsText: '',
|
||||
groupType: 'AIGC',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
sortBy: 'CreateTime',
|
||||
sortOrder: 'Desc'
|
||||
},
|
||||
totalCount: 0,
|
||||
items: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
prettyDetail() {
|
||||
return this.detailData ? JSON.stringify(this.detailData, null, 2) : '{}'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.search(1)
|
||||
},
|
||||
methods: {
|
||||
buildGroupIds() {
|
||||
return String(this.filters.groupIdsText || '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => !!x)
|
||||
},
|
||||
async createGroup() {
|
||||
const name = String(this.createForm.name || '').trim()
|
||||
if (!name) {
|
||||
this.$message.error('请填写名称')
|
||||
return
|
||||
}
|
||||
this.createLoading = true
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: 'api/portal/asset/post',
|
||||
method: 'POST',
|
||||
data: {
|
||||
Name: name,
|
||||
Description: String(this.createForm.description || '').trim(),
|
||||
GroupType: 'AIGC'
|
||||
}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
this.$message.success('新增成功')
|
||||
this.createForm.name = ''
|
||||
this.createForm.description = ''
|
||||
this.search(1)
|
||||
} else {
|
||||
this.$message.error(res.msg || '新增失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '新增失败')
|
||||
} finally {
|
||||
this.createLoading = false
|
||||
}
|
||||
},
|
||||
async search(page = this.filters.pageNumber) {
|
||||
this.filters.pageNumber = Number(page) || 1
|
||||
this.listLoading = true
|
||||
try {
|
||||
const payload = {
|
||||
Filter: {
|
||||
GroupType: this.filters.groupType || 'AIGC'
|
||||
},
|
||||
PageNumber: this.filters.pageNumber,
|
||||
PageSize: this.filters.pageSize,
|
||||
SortBy: this.filters.sortBy,
|
||||
SortOrder: this.filters.sortOrder
|
||||
}
|
||||
const name = String(this.filters.name || '').trim()
|
||||
if (name) payload.Filter.name = name
|
||||
const ids = this.buildGroupIds()
|
||||
if (ids.length) payload.Filter.GroupIds = ids
|
||||
|
||||
const res = await this.$axios({
|
||||
url: 'api/portal/asset/list',
|
||||
method: 'POST',
|
||||
data: payload
|
||||
})
|
||||
const data = res.data || {}
|
||||
this.totalCount = Number(data.TotalCount || 0)
|
||||
this.items = Array.isArray(data.Items) ? data.Items : []
|
||||
if (res.code !== 200) {
|
||||
this.$message.error(res.msg || '查询失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '查询失败')
|
||||
} finally {
|
||||
this.listLoading = false
|
||||
}
|
||||
},
|
||||
onPageSizeChange(size) {
|
||||
this.filters.pageSize = Number(size) || 10
|
||||
this.search(1)
|
||||
},
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
name: '',
|
||||
groupIdsText: '',
|
||||
groupType: 'AIGC',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
sortBy: 'CreateTime',
|
||||
sortOrder: 'Desc'
|
||||
}
|
||||
this.search(1)
|
||||
},
|
||||
async getDetail(item) {
|
||||
const id = item?.Id || item?.id
|
||||
if (!id) return
|
||||
this.detailLoadingId = id
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: 'api/portal/asset/get',
|
||||
method: 'POST',
|
||||
data: { Id: id }
|
||||
})
|
||||
if (res.code === 200) {
|
||||
this.detailData = res.data || {}
|
||||
this.detailVisible = true
|
||||
} else {
|
||||
this.$message.error(res.msg || '查询详情失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '查询详情失败')
|
||||
} finally {
|
||||
this.detailLoadingId = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.asset-group-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
background: #0a0b0d;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.ag-panel {
|
||||
background: rgba(22, 24, 30, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ag-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ag-form,
|
||||
.ag-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ag-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ag-field label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.ag-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ag-total {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.ag-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ag-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ag-table th,
|
||||
.ag-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ag-table th {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.ag-empty {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ag-pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ag-detail {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #d8f4f7;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ag-form,
|
||||
.ag-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
<template>
|
||||
<div class="asset-manage-page">
|
||||
<section class="asset-left">
|
||||
<div class="panel-title">素材组树</div>
|
||||
<a-button size="mini" type="outline" :loading="groupLoading" @click="loadGroups">刷新分组</a-button>
|
||||
<div class="group-tree">
|
||||
<div
|
||||
v-for="g in groups"
|
||||
:key="g.Id || g.id"
|
||||
:class="['group-node', { active: selectedGroupId === (g.Id || g.id) }]"
|
||||
@click="selectGroup(g)">
|
||||
<span class="folder-icon">📁</span>
|
||||
<div class="group-meta">
|
||||
<div class="group-name">{{ g.Name || g.name || (g.Id || g.id) }}</div>
|
||||
<div class="group-id">{{ g.Id || g.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!groups.length" class="empty-tip">暂无素材组,请先在“资源组管理”创建</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="asset-right">
|
||||
<div class="panel-title">新增素材</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>GroupId</label>
|
||||
<a-input v-model="createForm.groupId" placeholder="请选择左侧分组或手动输入" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>URL</label>
|
||||
<a-input v-model="createForm.url" placeholder="素材公网 URL(http/https)" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<a-input v-model="createForm.name" placeholder="素材名称(可选)" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AssetType</label>
|
||||
<a-select v-model="createForm.assetType">
|
||||
<a-option value="Image">Image</a-option>
|
||||
<a-option value="Video">Video</a-option>
|
||||
<a-option value="Audio">Audio</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="field actions">
|
||||
<a-button type="primary" :loading="createLoading" @click="createAsset">新增素材</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-title mtop">查询素材</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>GroupId</label>
|
||||
<a-input v-model="filters.groupId" placeholder="按组过滤(默认选中左侧)" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<a-input v-model="filters.name" placeholder="按名称过滤" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<a-select v-model="filters.status">
|
||||
<a-option value="">全部</a-option>
|
||||
<a-option value="Active">Active</a-option>
|
||||
<a-option value="Processing">Processing</a-option>
|
||||
<a-option value="Failed">Failed</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SortBy</label>
|
||||
<a-select v-model="filters.sortBy">
|
||||
<a-option value="CreateTime">CreateTime</a-option>
|
||||
<a-option value="UpdateTime">UpdateTime</a-option>
|
||||
<a-option value="GroupId">GroupId</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SortOrder</label>
|
||||
<a-select v-model="filters.sortOrder">
|
||||
<a-option value="Desc">Desc</a-option>
|
||||
<a-option value="Asc">Asc</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="field actions">
|
||||
<a-button type="primary" :loading="listLoading" @click="searchAssets(1)">查询</a-button>
|
||||
<a-button @click="resetFilters">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading">
|
||||
<div class="total-line">总数:{{ totalCount }}</div>
|
||||
<div class="table-wrap">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>GroupId</th>
|
||||
<th>AssetType</th>
|
||||
<th>Status</th>
|
||||
<th>CreateTime</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="it in items" :key="it.Id || it.id">
|
||||
<td>{{ it.Id || it.id }}</td>
|
||||
<td>{{ it.Name || it.name || '-' }}</td>
|
||||
<td class="url-cell">{{ it.URL || it.url || '-' }}</td>
|
||||
<td>{{ it.GroupId || it.groupId || '-' }}</td>
|
||||
<td>{{ it.AssetType || it.assetType || '-' }}</td>
|
||||
<td>{{ it.Status || it.status || '-' }}</td>
|
||||
<td>{{ it.CreateTime || it.createTime || '-' }}</td>
|
||||
<td>
|
||||
<a-button size="mini" type="outline" @click="getAsset(it)">详情</a-button>
|
||||
<a-button size="mini" status="danger" @click="deleteAsset(it)">删除</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!items.length">
|
||||
<td colspan="8" class="empty-tip">暂无素材</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<a-pagination
|
||||
:total="totalCount"
|
||||
:current="filters.pageNumber"
|
||||
:page-size="filters.pageSize"
|
||||
show-total
|
||||
show-jumper
|
||||
@change="searchAssets"
|
||||
@page-size-change="onPageSizeChange" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
|
||||
<a-modal v-model:visible="detailVisible" title="素材详情" :footer="false" width="700px">
|
||||
<pre class="detail-box">{{ detailText }}</pre>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const GROUP_LIST_API = 'api/portal/asset/list'
|
||||
const ASSET_CREATE_API = 'api/portal/asset/createAsset'
|
||||
const ASSET_LIST_API = 'api/portal/asset/listAssets'
|
||||
const ASSET_GET_API = 'api/portal/asset/getAsset'
|
||||
const ASSET_DELETE_API = 'api/portal/asset/deleteAsset'
|
||||
|
||||
export default {
|
||||
name: 'AssetManage',
|
||||
data() {
|
||||
return {
|
||||
groupLoading: false,
|
||||
createLoading: false,
|
||||
listLoading: false,
|
||||
groups: [],
|
||||
selectedGroupId: '',
|
||||
createForm: {
|
||||
groupId: '',
|
||||
url: '',
|
||||
name: '',
|
||||
assetType: 'Image'
|
||||
},
|
||||
filters: {
|
||||
groupId: '',
|
||||
name: '',
|
||||
status: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
sortBy: 'CreateTime',
|
||||
sortOrder: 'Desc'
|
||||
},
|
||||
totalCount: 0,
|
||||
items: [],
|
||||
detailVisible: false,
|
||||
detailData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
detailText() {
|
||||
return this.detailData ? JSON.stringify(this.detailData, null, 2) : '{}'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadGroups()
|
||||
},
|
||||
methods: {
|
||||
async loadGroups() {
|
||||
this.groupLoading = true
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: GROUP_LIST_API,
|
||||
method: 'POST',
|
||||
data: {
|
||||
Filter: { GroupType: 'AIGC' },
|
||||
PageNumber: 1,
|
||||
PageSize: 100,
|
||||
SortBy: 'CreateTime',
|
||||
SortOrder: 'Desc'
|
||||
}
|
||||
})
|
||||
this.groups = Array.isArray(res?.data?.Items) ? res.data.Items : []
|
||||
if (!this.selectedGroupId && this.groups.length) {
|
||||
const gid = this.groups[0].Id || this.groups[0].id
|
||||
this.selectedGroupId = gid
|
||||
this.createForm.groupId = gid
|
||||
this.filters.groupId = gid
|
||||
this.searchAssets(1)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '加载分组失败')
|
||||
} finally {
|
||||
this.groupLoading = false
|
||||
}
|
||||
},
|
||||
selectGroup(g) {
|
||||
const gid = g?.Id || g?.id
|
||||
this.selectedGroupId = gid
|
||||
this.createForm.groupId = gid
|
||||
this.filters.groupId = gid
|
||||
this.searchAssets(1)
|
||||
},
|
||||
async createAsset() {
|
||||
const groupId = String(this.createForm.groupId || '').trim()
|
||||
const url = String(this.createForm.url || '').trim()
|
||||
if (!groupId) return this.$message.error('请填写 GroupId')
|
||||
if (!/^https?:\/\//i.test(url)) return this.$message.error('URL 必须是 http(s) 地址')
|
||||
this.createLoading = true
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: ASSET_CREATE_API,
|
||||
method: 'POST',
|
||||
data: {
|
||||
GroupId: groupId,
|
||||
URL: url,
|
||||
Name: String(this.createForm.name || '').trim(),
|
||||
AssetType: this.createForm.assetType
|
||||
}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
this.$message.success('新增素材成功')
|
||||
this.createForm.url = ''
|
||||
this.createForm.name = ''
|
||||
this.searchAssets(1)
|
||||
} else {
|
||||
this.$message.error(res.msg || '新增素材失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '新增素材失败')
|
||||
} finally {
|
||||
this.createLoading = false
|
||||
}
|
||||
},
|
||||
buildListPayload() {
|
||||
const filter = {
|
||||
GroupType: 'AIGC'
|
||||
}
|
||||
const gid = String(this.filters.groupId || '').trim()
|
||||
if (gid) filter.GroupIds = [gid]
|
||||
const name = String(this.filters.name || '').trim()
|
||||
if (name) filter.Name = name
|
||||
if (this.filters.status) filter.Statuses = [this.filters.status]
|
||||
return {
|
||||
Filter: filter,
|
||||
PageNumber: this.filters.pageNumber,
|
||||
PageSize: this.filters.pageSize,
|
||||
SortBy: this.filters.sortBy,
|
||||
SortOrder: this.filters.sortOrder
|
||||
}
|
||||
},
|
||||
async searchAssets(page = this.filters.pageNumber) {
|
||||
this.filters.pageNumber = Number(page) || 1
|
||||
this.listLoading = true
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: ASSET_LIST_API,
|
||||
method: 'POST',
|
||||
data: this.buildListPayload()
|
||||
})
|
||||
this.totalCount = Number(res?.data?.TotalCount || 0)
|
||||
this.items = Array.isArray(res?.data?.Items) ? res.data.Items : []
|
||||
if (res.code !== 200) {
|
||||
this.$message.error(res.msg || '查询素材失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '查询素材失败')
|
||||
} finally {
|
||||
this.listLoading = false
|
||||
}
|
||||
},
|
||||
onPageSizeChange(size) {
|
||||
this.filters.pageSize = Number(size) || 10
|
||||
this.searchAssets(1)
|
||||
},
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
groupId: this.selectedGroupId || '',
|
||||
name: '',
|
||||
status: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
sortBy: 'CreateTime',
|
||||
sortOrder: 'Desc'
|
||||
}
|
||||
this.searchAssets(1)
|
||||
},
|
||||
async getAsset(it) {
|
||||
const id = it?.Id || it?.id
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: ASSET_GET_API,
|
||||
method: 'POST',
|
||||
data: { Id: id }
|
||||
})
|
||||
if (res.code === 200) {
|
||||
this.detailData = res.data || {}
|
||||
this.detailVisible = true
|
||||
} else {
|
||||
this.$message.error(res.msg || '查询详情失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '查询详情失败')
|
||||
}
|
||||
},
|
||||
deleteAsset(it) {
|
||||
const id = it?.Id || it?.id
|
||||
if (!id) return
|
||||
this.$confirm({
|
||||
title: '删除素材',
|
||||
content: `确认删除素材 ${id} 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: ASSET_DELETE_API,
|
||||
method: 'POST',
|
||||
data: { Id: id }
|
||||
})
|
||||
if (res.code === 200) {
|
||||
this.$message.success('删除成功')
|
||||
this.searchAssets(this.filters.pageNumber)
|
||||
} else {
|
||||
this.$message.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.asset-manage-page {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
min-height: 100%;
|
||||
background: #0a0b0d;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.asset-left,
|
||||
.asset-right {
|
||||
background: rgba(22, 24, 30, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.group-tree {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 72vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.group-node {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
.group-node:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.group-node.active {
|
||||
border-color: rgba(0, 202, 224, 0.45);
|
||||
background: rgba(0, 202, 224, 0.12);
|
||||
}
|
||||
.folder-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.group-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
.group-id {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
word-break: break-all;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.field.actions {
|
||||
align-self: end;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.mtop {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.total-line {
|
||||
margin: 10px 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.asset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.asset-table th,
|
||||
.asset-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.asset-table th {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.url-cell {
|
||||
max-width: 280px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.empty-tip {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.detail-box {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
color: #d8f4f7;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.asset-manage-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -23,10 +23,23 @@
|
|||
<template v-for="(seg, idx) in getRowPromptSegments(row)" :key="`${row.id || 'row'}_${idx}`">
|
||||
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
||||
<img
|
||||
v-else
|
||||
v-else-if="seg.type === 'image'"
|
||||
class="vg-chat-inline-ref-image"
|
||||
:src="seg.url"
|
||||
:alt="seg.token || ''" />
|
||||
<video
|
||||
v-else-if="seg.type === 'video'"
|
||||
class="vg-chat-inline-ref-video"
|
||||
:src="seg.url"
|
||||
controls
|
||||
muted
|
||||
preload="metadata" />
|
||||
<audio
|
||||
v-else-if="seg.type === 'audio'"
|
||||
class="vg-chat-inline-audio"
|
||||
:src="seg.url"
|
||||
controls
|
||||
preload="metadata" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
|
@ -254,7 +267,7 @@ export default {
|
|||
},
|
||||
allowedMediaTypes() {
|
||||
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') return ['image']
|
||||
if (this.videoMode === 'image-reference') return ['image']
|
||||
if (this.videoMode === 'image-reference') return ['image', 'video', 'audio']
|
||||
return ['image', 'video']
|
||||
},
|
||||
posterUrl() {
|
||||
|
|
@ -553,22 +566,32 @@ export default {
|
|||
}))
|
||||
},
|
||||
|
||||
getRowReferenceImageUrls(row) {
|
||||
const urls = []
|
||||
if (!row?.videoParams) return urls
|
||||
getRowReferenceMediaByType(row) {
|
||||
const images = []
|
||||
const videos = []
|
||||
const audios = []
|
||||
if (!row?.videoParams) return { images, videos, audios }
|
||||
try {
|
||||
const vp = typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
|
||||
const content = Array.isArray(vp?.content) ? vp.content : []
|
||||
for (const item of content) {
|
||||
if (item?.type !== 'image_url') continue
|
||||
if (item?.role && item.role !== 'reference_image') continue
|
||||
const url = item?.image_url?.url
|
||||
if (url) urls.push(url)
|
||||
if (item?.type === 'image_url' && (!item.role || item.role === 'reference_image')) {
|
||||
const url = item?.image_url?.url
|
||||
if (url) images.push(url)
|
||||
}
|
||||
if (item?.type === 'video_url' && item?.role === 'reference_video') {
|
||||
const url = item?.video_url?.url
|
||||
if (url) videos.push(url)
|
||||
}
|
||||
if (item?.type === 'audio_url' && item?.role === 'reference_audio') {
|
||||
const url = item?.audio_url?.url
|
||||
if (url) audios.push(url)
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore parse error
|
||||
}
|
||||
return urls
|
||||
return { images, videos, audios }
|
||||
},
|
||||
|
||||
getRowPromptSegments(row) {
|
||||
|
|
@ -589,25 +612,43 @@ export default {
|
|||
return segs
|
||||
}
|
||||
|
||||
// 参考图模式:按 [图n] 替换为对应 reference_image
|
||||
const refs = this.getRowReferenceImageUrls(row)
|
||||
if (!refs.length) {
|
||||
// 参考素材:[图片n][视频n][音频n] 与 content 中同类序号对应;兼容旧 [图n]
|
||||
const refs = this.getRowReferenceMediaByType(row)
|
||||
const hasRefPayload =
|
||||
refs.images.length > 0 || refs.videos.length > 0 || refs.audios.length > 0
|
||||
const hasRefTokens = /\[图片\d+\]|\[视频\d+\]|\[音频\d+\]|\[图\d+\]/.test(text)
|
||||
if (!hasRefPayload && !hasRefTokens) {
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
const tokenReg = /\[图(\d+)\]/g
|
||||
const tokenReg = /(\[图片(\d+)\]|\[视频(\d+)\]|\[音频(\d+)\]|\[图(\d+)\])/g
|
||||
const segments = []
|
||||
let last = 0
|
||||
let m
|
||||
while ((m = tokenReg.exec(text)) !== null) {
|
||||
const token = m[0]
|
||||
const idx = Number(m[1]) - 1
|
||||
const start = m.index
|
||||
if (start > last) {
|
||||
segments.push({ type: 'text', text: text.slice(last, start) })
|
||||
}
|
||||
const url = idx >= 0 ? refs[idx] : ''
|
||||
if (url) {
|
||||
segments.push({ type: 'image', url, token })
|
||||
let url = ''
|
||||
let segType = 'image'
|
||||
if (m[2] != null) {
|
||||
url = refs.images[Number(m[2]) - 1] || ''
|
||||
segType = 'image'
|
||||
} else if (m[3] != null) {
|
||||
url = refs.videos[Number(m[3]) - 1] || ''
|
||||
segType = 'video'
|
||||
} else if (m[4] != null) {
|
||||
url = refs.audios[Number(m[4]) - 1] || ''
|
||||
segType = 'audio'
|
||||
} else if (m[5] != null) {
|
||||
url = refs.images[Number(m[5]) - 1] || ''
|
||||
segType = 'image'
|
||||
}
|
||||
if (url && /^asset:\/\//i.test(url)) {
|
||||
segments.push({ type: 'text', text: token })
|
||||
} else if (url) {
|
||||
segments.push({ type: segType, url, token })
|
||||
} else {
|
||||
segments.push({ type: 'text', text: token })
|
||||
}
|
||||
|
|
@ -764,10 +805,6 @@ export default {
|
|||
}
|
||||
|
||||
if (this.videoMode === 'image-reference') {
|
||||
if (attachments.length === 0) {
|
||||
this.$message.error('请至少上传一张参考图')
|
||||
return
|
||||
}
|
||||
const compose = this.$refs.videoComposeRef
|
||||
const contentItems =
|
||||
compose && typeof compose.getImageReferenceContentItems === 'function'
|
||||
|
|
@ -778,13 +815,12 @@ export default {
|
|||
this.$message.error('参考图内容格式异常,请重试')
|
||||
return
|
||||
}
|
||||
const refUrls = contentItems.filter((x, idx) => idx > 0 && x?.type === 'image_url')
|
||||
if (!refUrls.length) {
|
||||
this.$message.error('请通过 @ 在描述中插入参考图')
|
||||
return
|
||||
}
|
||||
params.text = first.text || text
|
||||
params.content = contentItems
|
||||
const firstPreview = attachments.find((x) => x?.mediaType === 'image' && /^https?:\/\//i.test(String(x?.url || '')))
|
||||
if (firstPreview) {
|
||||
params.referenceUrl = firstPreview.url
|
||||
}
|
||||
}
|
||||
|
||||
const urlMap = {
|
||||
|
|
@ -1466,6 +1502,24 @@ export default {
|
|||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.vg-chat-inline-ref-video {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
max-height: 72px;
|
||||
vertical-align: middle;
|
||||
margin: 0 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.vg-chat-inline-audio {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 4px;
|
||||
max-width: 200px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.vg-chat-time {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
.gradle
|
||||
/build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
*.sql
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name undressing.top www.undressing.top;
|
||||
index index.html index.htm;
|
||||
ssl_certificate ssl/undressing.top.crt;
|
||||
ssl_certificate_key ssl/undressing.top.key;
|
||||
ssl_session_timeout 5m;
|
||||
# 优化加密套件配置,移除不安全的算法
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4:!3DES;
|
||||
# 移除不安全的 TLSv1.1,只保留 TLSv1.2 和 TLSv1.3
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
# 启用 SSL session 缓存,提高性能
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
root /data/web/client_web;
|
||||
autoindex off; # 禁用目录列表
|
||||
|
||||
# 安全头部
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
location ~* (\.git|\.env|composer\.json|\.log|\.sql)$ {
|
||||
deny all;
|
||||
}
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://10.0.0.167:8110;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Real-Port $remote_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header REMOTE-HOST $remote_addr;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# 缓冲配置,提高性能
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
}
|
||||
location / {
|
||||
root /data/web/client_web;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,9 @@ public class AiManagerApiController extends BaseController {
|
|||
@Anonymous
|
||||
public AjaxResult selectInfo(String aiType) {
|
||||
AiManager aiManager = aiManagerService.selectAiManagerByType(aiType);
|
||||
if (aiManager == null) {
|
||||
return AjaxResult.error("该功能未配置或已停用");
|
||||
}
|
||||
aiManager.setPrompt(null);
|
||||
return AjaxResult.success(aiManager);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@ package com.ruoyi.api;
|
|||
|
||||
import com.ruoyi.ai.domain.AiSampleAmount;
|
||||
import com.ruoyi.ai.domain.AiSampleAmountRecord;
|
||||
import com.ruoyi.ai.domain.AiStatistics;
|
||||
import com.ruoyi.ai.domain.enums.AiConfigEnum;
|
||||
import com.ruoyi.ai.mapper.AiSampleAmountMapper;
|
||||
import com.ruoyi.ai.service.EmailVerifyService;
|
||||
import com.ruoyi.ai.service.IAiSampleAmountRecordService;
|
||||
import com.ruoyi.ai.service.IAiStatisticsService;
|
||||
import com.ruoyi.ai.service.IAiUserService;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.constant.BalanceChangerConstants;
|
||||
|
|
@ -22,10 +19,8 @@ import com.ruoyi.common.core.domain.model.RegisterAiUserBody;
|
|||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.exception.job.TaskException;
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
import com.ruoyi.common.utils.IpCountryQueryByApi;
|
||||
import com.ruoyi.common.utils.MessageUtils;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.ip.IpUtils;
|
||||
import com.ruoyi.framework.web.service.SysLoginService;
|
||||
import com.ruoyi.quartz.domain.SysJob;
|
||||
import com.ruoyi.quartz.service.ISysJobService;
|
||||
|
|
@ -40,7 +35,10 @@ import org.springframework.validation.annotation.Validated;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.mail.MessagingException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -104,8 +102,11 @@ public class AiUserApiController extends BaseController {
|
|||
// 查询启用状态体验金活动
|
||||
AiSampleAmount aiSampleAmount = aiSampleAmountMapper.getSampleAmount();
|
||||
if (aiSampleAmount != null) {
|
||||
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
String orderNo = dateTime + uuid;
|
||||
// 余额变更记录
|
||||
aiUserService.addUserBalance(aiUser.getId(), aiSampleAmount.getAmount(), BalanceChangerConstants.EXPERIENCE_GOLD_GIFT);
|
||||
aiUserService.addUserBalance(orderNo, aiUser.getId(), aiSampleAmount.getAmount(), BalanceChangerConstants.EXPERIENCE_GOLD_GIFT);
|
||||
// 新增体验金记录
|
||||
AiSampleAmountRecord aiSampleAmountRecord = new AiSampleAmountRecord();
|
||||
aiSampleAmountRecord.setUserId(aiUser.getId());
|
||||
|
|
@ -218,6 +219,7 @@ public class AiUserApiController extends BaseController {
|
|||
|
||||
/**
|
||||
* 查询返佣比例配置
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/getRebateConfig")
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import com.ruoyi.common.annotation.Anonymous;
|
|||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.model.LoginAiUser;
|
||||
import com.ruoyi.common.utils.AwsS3Util;
|
||||
import com.ruoyi.common.utils.RandomStringUtil;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.TencentCosUtil;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -36,13 +36,21 @@ import java.util.regex.Pattern;
|
|||
public class ByteApiController extends BaseController {
|
||||
|
||||
private final IByteService byteService;
|
||||
private final AwsS3Util awsS3Util;
|
||||
private final TencentCosUtil tencentCosUtil;
|
||||
private final IAiOrderService aiOrderService;
|
||||
private final IAiManagerService managerService;
|
||||
private final IAiTagService aiTagService;
|
||||
@Value("${byteapi.callBackUrl}")
|
||||
private String url;
|
||||
|
||||
// 火山引擎配置
|
||||
@Value("${volcengine.ark.apiKey}")
|
||||
private String volcApiKey;
|
||||
@Value("${volcengine.ark.baseUrl}")
|
||||
private String volcBaseUrl;
|
||||
@Value("${volcengine.ark.callbackUrl}")
|
||||
private String volcCallbackUrl;
|
||||
|
||||
@PostMapping("/promptToImg")
|
||||
@ApiOperation("文生图")
|
||||
public AjaxResult promptToImg(@RequestBody ByteApiRequest request) {
|
||||
|
|
@ -51,9 +59,12 @@ public class ByteApiController extends BaseController {
|
|||
return AjaxResult.error("functionType is null");
|
||||
}
|
||||
|
||||
String mode = request.getMode() != null ? request.getMode() : "image-to-video";
|
||||
AiManager aiManager = managerService.selectAiManagerByType(functionType);
|
||||
String tags = request.getTags();
|
||||
String text = "";
|
||||
String text = request.getText();
|
||||
|
||||
// 如果使用标签系统生成prompt
|
||||
if (StringUtils.isNotEmpty(tags)) {
|
||||
List<AiTag> aiTags = aiTagService.selectAiTagListByIds(request.getTags(), aiManager.getParentIdSort());
|
||||
List<String> tagPrompts = new ArrayList<>();
|
||||
|
|
@ -70,22 +81,24 @@ public class ByteApiController extends BaseController {
|
|||
tagPrompts.add(p);
|
||||
}
|
||||
text = StringUtils.replacePlaceholders(aiManager.getPrompt(), tagPrompts);
|
||||
} else {
|
||||
text = aiManager.getPrompt();
|
||||
}
|
||||
|
||||
if (StringUtils.isEmpty(text)) {
|
||||
return AjaxResult.error("text is null");
|
||||
}
|
||||
|
||||
|
||||
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
|
||||
try {
|
||||
if (aiOrder == null) {
|
||||
return AjaxResult.error(-1, "You have a low balance, please recharge");
|
||||
}
|
||||
aiOrder.setText(text);
|
||||
aiOrder.setMode(mode); // 记录生成模式
|
||||
|
||||
ByteBodyReq byteBodyReq = new ByteBodyReq();
|
||||
byteBodyReq.setModel("ep-20251104104536-2gpgz");
|
||||
// model由前端传入,默认为Seedance 2.0
|
||||
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
|
||||
request.getModel() : "ep-20260326165811-dlkth");
|
||||
byteBodyReq.setPrompt(text);
|
||||
byteBodyReq.setSequential_image_generation("disabled");
|
||||
byteBodyReq.setResponse_format("url");
|
||||
|
|
@ -96,7 +109,7 @@ public class ByteApiController extends BaseController {
|
|||
List<ByteDataRes> data = byteBodyRes.getData();
|
||||
ByteDataRes byteDataRes = data.get(0);
|
||||
String url = byteDataRes.getUrl();
|
||||
url = awsS3Util.uploadFileByUrl(url);
|
||||
url = tencentCosUtil.uploadFileByUrl(url);
|
||||
if (url == null) {
|
||||
// 判断生成失败,退回金额逻辑
|
||||
aiOrderService.orderFailure(aiOrder);
|
||||
|
|
@ -175,7 +188,7 @@ public class ByteApiController extends BaseController {
|
|||
List<ByteDataRes> data = byteBodyRes.getData();
|
||||
ByteDataRes byteDataRes = data.get(0);
|
||||
String url = byteDataRes.getUrl();
|
||||
url = awsS3Util.uploadFileByUrl(url);
|
||||
url = tencentCosUtil.uploadFileByUrl(url);
|
||||
if (url == null) {
|
||||
// 判断生成失败,退回金额逻辑
|
||||
aiOrderService.orderFailure(aiOrder);
|
||||
|
|
@ -191,7 +204,7 @@ public class ByteApiController extends BaseController {
|
|||
}
|
||||
|
||||
@PostMapping("/imgToVideo")
|
||||
@ApiOperation("图生视频")
|
||||
@ApiOperation("图生视频 (Seedance 2.0)")
|
||||
public AjaxResult imgToVideo(@RequestBody ByteApiRequest request) throws Exception {
|
||||
String functionType = request.getFunctionType();
|
||||
if (null == functionType) {
|
||||
|
|
@ -235,51 +248,56 @@ public class ByteApiController extends BaseController {
|
|||
return AjaxResult.error(-1, "You have a low balance, please recharge");
|
||||
}
|
||||
try {
|
||||
// String text = request.getText();
|
||||
// if (StringUtils.isBlank(text)) {
|
||||
// return AjaxResult.error("text is null");
|
||||
// }
|
||||
// String tags = request.getTags();
|
||||
// if (StringUtils.isNotBlank(tags)) {
|
||||
// text = "(优先考虑以下关键词:" + tags + ")";
|
||||
// }
|
||||
aiOrder.setText(text);
|
||||
|
||||
aiOrder.setImg1(firstUrl.toString());
|
||||
Integer duration = request.getDuration();
|
||||
|
||||
Integer duration = request.getDuration() != null ? request.getDuration() : 4;
|
||||
|
||||
ByteBodyReq byteBodyReq = new ByteBodyReq();
|
||||
byteBodyReq.setModel("ep-20251113072240-cfxlz");
|
||||
byteBodyReq.setCallback_url(url + "/api/ai/callBack");
|
||||
List<ContentItem> content = new ArrayList<>();
|
||||
ContentItem contentItem = new ContentItem();
|
||||
contentItem.setType("text");
|
||||
contentItem.setText(text + " --dur " + duration + " --fps 24 --rs 720p --wm false --cf false");
|
||||
content.add(contentItem);
|
||||
// model由前端传入,默认为Seedance2.0
|
||||
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
|
||||
request.getModel() : "ep-20260326165811-dlkth");
|
||||
byteBodyReq.setCallback_url(volcCallbackUrl);
|
||||
|
||||
ContentItem contentItem1 = new ContentItem();
|
||||
contentItem1.setType("image_url");
|
||||
contentItem1.setRole("first_frame");
|
||||
ImageUrl imageUrl1 = new ImageUrl();
|
||||
imageUrl1.setUrl(firstUrl.toString());
|
||||
contentItem1.setImageUrl(imageUrl1);
|
||||
content.add(contentItem1);
|
||||
// 构建符合火山引擎格式的content
|
||||
List<ContentItem> contentList = new ArrayList<>();
|
||||
|
||||
// 文本提示词
|
||||
ContentItem textItem = new ContentItem();
|
||||
textItem.setType("text");
|
||||
textItem.setText(text);
|
||||
contentList.add(textItem);
|
||||
|
||||
// 首帧图片
|
||||
ContentItem firstFrameItem = new ContentItem();
|
||||
firstFrameItem.setType("image_url");
|
||||
firstFrameItem.setRole("first_frame");
|
||||
ImageUrl firstImageUrl = new ImageUrl();
|
||||
firstImageUrl.setUrl(firstUrl.toString());
|
||||
firstFrameItem.setImageUrl(firstImageUrl);
|
||||
contentList.add(firstFrameItem);
|
||||
|
||||
// 如果有尾帧
|
||||
String lastUrl = request.getLastUrl();
|
||||
if (StringUtils.isNotBlank(lastUrl)) {
|
||||
ContentItem contentItem2 = new ContentItem();
|
||||
contentItem2.setType("image_url");
|
||||
contentItem2.setRole("last_frame");
|
||||
ImageUrl imageUrl2 = new ImageUrl();
|
||||
imageUrl2.setUrl(lastUrl);
|
||||
contentItem2.setImageUrl(imageUrl2);
|
||||
content.add(contentItem2);
|
||||
ContentItem lastFrameItem = new ContentItem();
|
||||
lastFrameItem.setType("image_url");
|
||||
lastFrameItem.setRole("last_frame");
|
||||
ImageUrl lastImageUrl = new ImageUrl();
|
||||
lastImageUrl.setUrl(lastUrl);
|
||||
lastFrameItem.setImageUrl(lastImageUrl);
|
||||
contentList.add(lastFrameItem);
|
||||
aiOrder.setImg2(lastUrl);
|
||||
}
|
||||
byteBodyReq.setContent(content);
|
||||
|
||||
byteBodyReq.setContent(contentList);
|
||||
byteBodyReq.setDuration(duration);
|
||||
byteBodyReq.setResolution("720p");
|
||||
byteBodyReq.setRatio("3:4");
|
||||
|
||||
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq);
|
||||
String id = byteBodyRes.getId();
|
||||
if (id == null) {
|
||||
// 判断生成失败,退回金额逻辑
|
||||
aiOrderService.orderFailure(aiOrder);
|
||||
return AjaxResult.error(-2, "generation failed, balance has been refunded");
|
||||
}
|
||||
|
|
@ -299,7 +317,7 @@ public class ByteApiController extends BaseController {
|
|||
if ("succeeded".equals(byteBodyRes.getStatus())) {
|
||||
content content = byteBodyRes.getContent();
|
||||
String videoUrl = content.getVideo_url();
|
||||
videoUrl = awsS3Util.uploadFileByUrl(videoUrl);
|
||||
videoUrl = tencentCosUtil.uploadFileByUrl(videoUrl);
|
||||
content.setVideo_url(videoUrl);
|
||||
AiOrder aiOrderByResult = aiOrderService.getAiOrderByResult(id);
|
||||
AiOrder aiOrder = new AiOrder();
|
||||
|
|
@ -311,24 +329,34 @@ public class ByteApiController extends BaseController {
|
|||
return AjaxResult.success(byteBodyRes);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/callBack")
|
||||
@ApiOperation("视频下载回调")
|
||||
@GetMapping(value = "/volcCallback")
|
||||
@ApiOperation("火山引擎视频回调")
|
||||
@Anonymous
|
||||
public AjaxResult callBack(@PathVariable("id") ByteBodyRes byteBodyRes) throws Exception {
|
||||
public AjaxResult volcCallback(@RequestBody ByteBodyRes byteBodyRes) throws Exception {
|
||||
if ("succeeded".equals(byteBodyRes.getStatus())) {
|
||||
String id = byteBodyRes.getId();
|
||||
content content = byteBodyRes.getContent();
|
||||
String videoUrl = content.getVideo_url();
|
||||
videoUrl = awsS3Util.uploadFileByUrl(videoUrl);
|
||||
content.setVideo_url(videoUrl);
|
||||
AiOrder aiOrderByResult = aiOrderService.getAiOrderByResult(id);
|
||||
AiOrder aiOrder = new AiOrder();
|
||||
aiOrder.setId(aiOrderByResult.getId());
|
||||
aiOrder.setResult(videoUrl);
|
||||
// aiOrder.setUpdateBy(SecurityUtils.getLoginAiUser().getUsername());
|
||||
aiOrderService.updateAiOrder(aiOrder);
|
||||
content contentObj = byteBodyRes.getContent();
|
||||
if (contentObj != null && StringUtils.isNotEmpty(contentObj.getVideo_url())) {
|
||||
String videoUrl = contentObj.getVideo_url();
|
||||
videoUrl = tencentCosUtil.uploadFileByUrl(videoUrl);
|
||||
contentObj.setVideo_url(videoUrl);
|
||||
|
||||
AiOrder aiOrderByResult = aiOrderService.getAiOrderByResult(id);
|
||||
if (aiOrderByResult != null) {
|
||||
AiOrder aiOrder = new AiOrder();
|
||||
aiOrder.setId(aiOrderByResult.getId());
|
||||
aiOrder.setResult(videoUrl);
|
||||
aiOrderService.updateAiOrder(aiOrder);
|
||||
}
|
||||
}
|
||||
}
|
||||
return AjaxResult.success(byteBodyRes);
|
||||
return AjaxResult.success("callback success");
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/cancel")
|
||||
@ApiOperation("取消视频生成任务")
|
||||
public AjaxResult cancelTask(@PathVariable("id") String id) throws Exception {
|
||||
return byteService.cancelVideoTask(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.ruoyi.api;
|
||||
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.TencentCosUtil;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiParam;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* COS 上传兼容接口
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cos")
|
||||
@Api(tags = "COS文件上传")
|
||||
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||
public class CosController {
|
||||
|
||||
private final TencentCosUtil tencentCosUtil;
|
||||
|
||||
@ApiOperation("COS上传接口")
|
||||
@PostMapping("/upload")
|
||||
public AjaxResult upload(
|
||||
@ApiParam(name = "file", value = "文件", required = true)
|
||||
@RequestParam("file") MultipartFile file) throws Exception {
|
||||
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true);
|
||||
AjaxResult ajax = AjaxResult.success(uploadUrl);
|
||||
ajax.put("url", uploadUrl);
|
||||
ajax.put("oldName", file.getOriginalFilename());
|
||||
return ajax;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package com.ruoyi.api;
|
||||
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.AwsS3Util;
|
||||
import com.ruoyi.common.utils.TencentCosUtil;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiParam;
|
||||
|
|
@ -18,7 +18,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
@Api(tags = "文件上传")
|
||||
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||
public class FileController {
|
||||
private final AwsS3Util awsS3Util;
|
||||
private final TencentCosUtil tencentCosUtil;
|
||||
|
||||
/**
|
||||
* 文件上传
|
||||
|
|
@ -30,7 +30,7 @@ public class FileController {
|
|||
@ApiParam(name = "file", value = "文件", required = true)
|
||||
@RequestParam("file") MultipartFile file) throws Exception {
|
||||
AjaxResult ajax = AjaxResult.success();
|
||||
String uploadUrl = awsS3Util.uploadMultipartFile(file, true);
|
||||
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true);
|
||||
ajax.put("url", uploadUrl);
|
||||
return ajax;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ public class KadaPaymentJava {
|
|||
map.put("pm", "QRPH");
|
||||
map.put("ref", orderNo);
|
||||
map.put("payer", payer);
|
||||
map.put("redirect", "www.google.com/recharge");
|
||||
map.put("callbackUrl", "www.google.com/api/pay/kada-callBack");
|
||||
map.put("redirect", "https://undressing.top/recharge");
|
||||
map.put("callbackUrl", "https://undressing.top/api/pay/kada-callBack");
|
||||
|
||||
return JSON.toJSONString(map);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import com.ruoyi.ai.service.IAiRechargeService;
|
|||
import com.ruoyi.ai.service.IJinShaService;
|
||||
import com.ruoyi.ai.service.IKaDaService;
|
||||
import com.ruoyi.ai.service.IYuZhouService;
|
||||
import com.ruoyi.ai.service.IVmService;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.ip.IpUtils;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -25,6 +27,7 @@ public class PayController {
|
|||
private final IKaDaService kaDaService;
|
||||
private final IAiRechargeService aiRechargeService;
|
||||
private final IYuZhouService yuZhouService;
|
||||
private final IVmService vmService;
|
||||
|
||||
/**
|
||||
* jinsha请求支付
|
||||
|
|
@ -85,4 +88,109 @@ public class PayController {
|
|||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* VM请求支付
|
||||
*/
|
||||
@PostMapping("/vm-pay")
|
||||
@ApiOperation("VM请求支付")
|
||||
public AjaxResult vmPay(@RequestBody VmPayReq req, HttpServletRequest request) throws Exception {
|
||||
// 获取用户真实IP地址
|
||||
String clientIp = IpUtils.getIpAddr(request);
|
||||
PayResVO payResVO = vmService.vmPay(req.getGearId(), req.getVmCardInfo(), clientIp);
|
||||
return AjaxResult.success(payResVO);
|
||||
}
|
||||
|
||||
@PostMapping("/vm-callBack")
|
||||
@ApiOperation("VM支付回调")
|
||||
@Anonymous
|
||||
public String vmCallBack(HttpServletRequest request) {
|
||||
// 记录请求的Content-Type
|
||||
String contentType = request.getContentType();
|
||||
|
||||
// 从请求中解析表单参数
|
||||
VmCallBackReq req = new VmCallBackReq();
|
||||
|
||||
// 记录所有请求参数用于调试
|
||||
java.util.Enumeration<String> paramNames = request.getParameterNames();
|
||||
java.util.Map<String, String> allParams = new java.util.HashMap<>();
|
||||
while (paramNames.hasMoreElements()) {
|
||||
String paramName = paramNames.nextElement();
|
||||
String paramValue = request.getParameter(paramName);
|
||||
allParams.put(paramName, paramValue);
|
||||
}
|
||||
org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(PayController.class);
|
||||
log.info("VM支付回调请求参数: {}, Content-Type: {}", allParams, contentType);
|
||||
|
||||
req.setMchNo(request.getParameter("mchNo"));
|
||||
req.setAppId(request.getParameter("appId"));
|
||||
req.setMchOrderNo(request.getParameter("mchOrderNo"));
|
||||
req.setPayOrderId(request.getParameter("payOrderId"));
|
||||
req.setWayCode(request.getParameter("wayCode"));
|
||||
|
||||
// 解析整数类型字段
|
||||
String amountStr = request.getParameter("amount");
|
||||
if (amountStr != null && !amountStr.isEmpty()) {
|
||||
try {
|
||||
req.setAmount(Integer.parseInt(amountStr));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("VM支付回调amount解析失败: {}", amountStr);
|
||||
}
|
||||
}
|
||||
|
||||
req.setCurrency(request.getParameter("currency"));
|
||||
req.setIfCode(request.getParameter("ifCode"));
|
||||
req.setSubject(request.getParameter("subject"));
|
||||
req.setBody(request.getParameter("body"));
|
||||
req.setClientIp(request.getParameter("clientIp"));
|
||||
|
||||
// 解析订单状态(文档字段名:state)
|
||||
String stateStr = request.getParameter("state");
|
||||
if (stateStr != null && !stateStr.isEmpty()) {
|
||||
try {
|
||||
req.setState(Integer.parseInt(stateStr));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("VM支付回调state解析失败: {}", stateStr);
|
||||
}
|
||||
}
|
||||
|
||||
req.setChannelOrderNo(request.getParameter("channelOrderNo"));
|
||||
req.setErrCode(request.getParameter("errCode"));
|
||||
req.setErrMsg(request.getParameter("errMsg"));
|
||||
req.setExtParam(request.getParameter("extParam"));
|
||||
|
||||
// 解析时间戳:createdAt、successTime、reqTime(文档字段名)
|
||||
String createdAtStr = request.getParameter("createdAt");
|
||||
if (createdAtStr != null && !createdAtStr.isEmpty()) {
|
||||
try {
|
||||
req.setCreatedAt(Long.parseLong(createdAtStr));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("VM支付回调createdAt解析失败: {}", createdAtStr);
|
||||
}
|
||||
}
|
||||
String successTimeStr = request.getParameter("successTime");
|
||||
if (successTimeStr != null && !successTimeStr.isEmpty()) {
|
||||
try {
|
||||
req.setSuccessTime(Long.parseLong(successTimeStr));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("VM支付回调successTime解析失败: {}", successTimeStr);
|
||||
}
|
||||
}
|
||||
String reqTimeStr = request.getParameter("reqTime");
|
||||
if (reqTimeStr != null && !reqTimeStr.isEmpty()) {
|
||||
try {
|
||||
req.setReqTime(Long.parseLong(reqTimeStr));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("VM支付回调reqTime解析失败: {}", reqTimeStr);
|
||||
}
|
||||
}
|
||||
|
||||
req.setSign(request.getParameter("sign"));
|
||||
req.setSignType(request.getParameter("signType"));
|
||||
|
||||
log.info("VM支付回调解析后的对象: mchOrderNo={}, state={}, sign={}",
|
||||
req.getMchOrderNo(), req.getState(), req.getSign());
|
||||
|
||||
return vmService.vmCallBack(req);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,519 @@
|
|||
package com.ruoyi.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ruoyi.ai.domain.AiOrder;
|
||||
import com.ruoyi.ai.domain.ByteBodyReq;
|
||||
import com.ruoyi.ai.domain.ByteBodyRes;
|
||||
import com.ruoyi.ai.domain.ContentItem;
|
||||
import com.ruoyi.ai.domain.ImageUrl;
|
||||
import com.ruoyi.ai.domain.content;
|
||||
import com.ruoyi.ai.service.IAiOrderService;
|
||||
import com.ruoyi.ai.service.IByteDeptApiKeyService;
|
||||
import com.ruoyi.ai.service.IByteService;
|
||||
import com.ruoyi.api.request.PortalVideoGenRequest;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.TencentCosUtil;
|
||||
import com.ruoyi.config.PortalVideoProperties;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 门户视频生成:按用户二级部门 byte_api_key 调用火山;任务列表含库表与火山过滤列表。
|
||||
*/
|
||||
@Api(tags = "门户-视频生成")
|
||||
@RestController
|
||||
@RequestMapping("/api/portal/video")
|
||||
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||
public class PortalVideoController extends BaseController {
|
||||
|
||||
private final IByteService byteService;
|
||||
private final IByteDeptApiKeyService byteDeptApiKeyService;
|
||||
private final IAiOrderService aiOrderService;
|
||||
private final TencentCosUtil tencentCosUtil;
|
||||
private final PortalVideoProperties portalVideoProperties;
|
||||
|
||||
@Value("${volcengine.ark.callbackUrl:}")
|
||||
private String volcCallbackUrl;
|
||||
|
||||
private static final ObjectMapper OM = new ObjectMapper();
|
||||
|
||||
private String apiKey() {
|
||||
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||
}
|
||||
|
||||
/** 与 ai_manager.type、portal.video.function-type 对齐,用于扣费 */
|
||||
private String resolveFunctionType(PortalVideoGenRequest req) {
|
||||
if (StringUtils.isNotEmpty(req.getFunctionType())) {
|
||||
return req.getFunctionType();
|
||||
}
|
||||
if (StringUtils.isNotEmpty(portalVideoProperties.getFunctionType())) {
|
||||
return portalVideoProperties.getFunctionType();
|
||||
}
|
||||
return "21";
|
||||
}
|
||||
|
||||
private void applyOptionalParams(ByteBodyReq body, PortalVideoGenRequest req) {
|
||||
PortalVideoProperties.Defaults d = portalVideoProperties.getDefaults();
|
||||
body.setDuration(req.getDuration() != null ? req.getDuration() : d.getDuration());
|
||||
body.setResolution(StringUtils.isNotEmpty(req.getResolution()) ? req.getResolution() : d.getResolution());
|
||||
body.setRatio(StringUtils.isNotEmpty(req.getRatio()) ? req.getRatio() : d.getRatio());
|
||||
if (StringUtils.isNotEmpty(volcCallbackUrl)) {
|
||||
body.setCallback_url(volcCallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private ByteBodyReq newVideoBody(PortalVideoGenRequest req, List<ContentItem> content) {
|
||||
String modelId = StringUtils.isNotEmpty(req.getModel()) ? req.getModel() : portalVideoProperties.resolveDefaultModelId();
|
||||
if (StringUtils.isEmpty(modelId)) {
|
||||
throw new ServiceException("未配置门户视频模型,请在 application.yml 的 portal.video 中配置 models 与 defaults.model");
|
||||
}
|
||||
ByteBodyReq body = new ByteBodyReq();
|
||||
body.setModel(modelId);
|
||||
body.setContent(content);
|
||||
applyOptionalParams(body, req);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void applyOrderImages(AiOrder aiOrder, PortalVideoGenRequest req) {
|
||||
if (StringUtils.isNotEmpty(req.getFirstUrl())) {
|
||||
aiOrder.setImg1(req.getFirstUrl());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(req.getLastUrl())) {
|
||||
aiOrder.setImg2(req.getLastUrl());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(req.getReferenceUrl())) {
|
||||
aiOrder.setImg1(req.getReferenceUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入订单:提示词、生成模式、图床 URL,以及模型/时长/分辨率/比例与完整 JSON 参数(便于对账与审计)。
|
||||
*/
|
||||
private void fillVideoOrderRecord(AiOrder aiOrder, PortalVideoGenRequest req, String mode, ByteBodyReq body, String functionTypeResolved) {
|
||||
aiOrder.setText(req.getText());
|
||||
aiOrder.setMode(mode);
|
||||
applyOrderImages(aiOrder, req);
|
||||
if (req.getDuration() != null) {
|
||||
aiOrder.setDuration(req.getDuration());
|
||||
} else if (body.getDuration() != null) {
|
||||
aiOrder.setDuration(body.getDuration());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(body.getResolution())) {
|
||||
aiOrder.setResolution(body.getResolution());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(body.getRatio())) {
|
||||
aiOrder.setRatio(body.getRatio());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(body.getModel())) {
|
||||
aiOrder.setModel(body.getModel());
|
||||
}
|
||||
try {
|
||||
Map<String, Object> snap = new LinkedHashMap<>();
|
||||
snap.put("generationMode", mode);
|
||||
snap.put("prompt", req.getText());
|
||||
snap.put("functionType", functionTypeResolved);
|
||||
snap.put("model", body.getModel());
|
||||
snap.put("duration", body.getDuration());
|
||||
snap.put("resolution", body.getResolution());
|
||||
snap.put("ratio", body.getRatio());
|
||||
if (StringUtils.isNotEmpty(req.getFirstUrl())) {
|
||||
snap.put("firstUrl", req.getFirstUrl());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(req.getLastUrl())) {
|
||||
snap.put("lastUrl", req.getLastUrl());
|
||||
}
|
||||
if (StringUtils.isNotEmpty(req.getReferenceUrl())) {
|
||||
snap.put("referenceUrl", req.getReferenceUrl());
|
||||
}
|
||||
if (req.getContent() != null && !req.getContent().isEmpty()) {
|
||||
snap.put("content", req.getContent());
|
||||
}
|
||||
aiOrder.setVideoParams(OM.writeValueAsString(snap));
|
||||
} catch (Exception e) {
|
||||
aiOrder.setVideoParams("{\"error\":\"video_params_serialize_failed\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入 video_params.volcTaskId,任务成功后 result 会改为成品 URL,仍应用此 id 校验归属与轮询 */
|
||||
private void mergeVolcTaskIdIntoVideoParams(AiOrder aiOrder, String volcTaskId) {
|
||||
if (StringUtils.isEmpty(volcTaskId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ObjectNode node;
|
||||
if (StringUtils.isNotEmpty(aiOrder.getVideoParams())) {
|
||||
JsonNode existing = OM.readTree(aiOrder.getVideoParams());
|
||||
if (existing instanceof ObjectNode) {
|
||||
node = (ObjectNode) existing;
|
||||
} else {
|
||||
node = OM.createObjectNode();
|
||||
node.set("snapshotBeforeVolcId", existing);
|
||||
}
|
||||
} else {
|
||||
node = OM.createObjectNode();
|
||||
}
|
||||
node.put("volcTaskId", volcTaskId);
|
||||
aiOrder.setVideoParams(OM.writeValueAsString(node));
|
||||
} catch (Exception e) {
|
||||
aiOrder.setVideoParams("{\"volcTaskId\":\"" + volcTaskId.replace("\"", "") + "\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) {
|
||||
String functionType = resolveFunctionType(req);
|
||||
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
|
||||
if (aiOrder == null) {
|
||||
return AjaxResult.error(-1, "You have a low balance, please recharge");
|
||||
}
|
||||
try {
|
||||
fillVideoOrderRecord(aiOrder, req, mode, byteBodyReq, functionType);
|
||||
aiOrderService.updateAiOrder(aiOrder);
|
||||
|
||||
String key = apiKey();
|
||||
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq, key);
|
||||
String id = byteBodyRes.getId();
|
||||
if (id == null) {
|
||||
aiOrderService.orderFailure(aiOrder);
|
||||
return AjaxResult.error(-2, "generation failed, balance has been refunded");
|
||||
}
|
||||
mergeVolcTaskIdIntoVideoParams(aiOrder, id);
|
||||
aiOrder.setResult(id);
|
||||
aiOrderService.orderSuccess(aiOrder);
|
||||
return AjaxResult.success(byteBodyRes);
|
||||
} catch (Exception e) {
|
||||
aiOrderService.orderFailure(aiOrder);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/text-to-video")
|
||||
@ApiOperation("文生视频")
|
||||
public AjaxResult textToVideo(@RequestBody PortalVideoGenRequest request) {
|
||||
List<ContentItem> contentList;
|
||||
if (request.getContent() != null && !request.getContent().isEmpty()) {
|
||||
contentList = request.getContent();
|
||||
} else {
|
||||
if (StringUtils.isEmpty(request.getText())) {
|
||||
return AjaxResult.error("请输入视频描述文本");
|
||||
}
|
||||
contentList = new ArrayList<>();
|
||||
ContentItem textItem = new ContentItem();
|
||||
textItem.setType("text");
|
||||
textItem.setText(request.getText());
|
||||
contentList.add(textItem);
|
||||
}
|
||||
|
||||
ByteBodyReq body = newVideoBody(request, contentList);
|
||||
return submitOrderAndCreate(request, "text-to-video", body);
|
||||
}
|
||||
|
||||
@PostMapping("/image-first-frame")
|
||||
@ApiOperation("图生视频-基于首帧")
|
||||
public AjaxResult imageFirstFrame(@RequestBody PortalVideoGenRequest request) {
|
||||
if (StringUtils.isEmpty(request.getFirstUrl())) {
|
||||
return AjaxResult.error("请上传首帧图片");
|
||||
}
|
||||
if (StringUtils.isEmpty(request.getText())) {
|
||||
return AjaxResult.error("请输入视频描述文本");
|
||||
}
|
||||
List<ContentItem> contentList = buildTextAndFirstFrame(request.getText(), request.getFirstUrl());
|
||||
ByteBodyReq body = newVideoBody(request, contentList);
|
||||
return submitOrderAndCreate(request, "image-first-frame", body);
|
||||
}
|
||||
|
||||
@PostMapping("/image-first-last-frame")
|
||||
@ApiOperation("图生视频-基于首尾帧")
|
||||
public AjaxResult imageFirstLastFrame(@RequestBody PortalVideoGenRequest request) {
|
||||
if (StringUtils.isEmpty(request.getFirstUrl()) || StringUtils.isEmpty(request.getLastUrl())) {
|
||||
return AjaxResult.error("请同时上传首帧与尾帧图片");
|
||||
}
|
||||
if (StringUtils.isEmpty(request.getText())) {
|
||||
return AjaxResult.error("请输入视频描述文本");
|
||||
}
|
||||
List<ContentItem> contentList = buildTextAndFirstFrame(request.getText(), request.getFirstUrl());
|
||||
ContentItem lastFrameItem = new ContentItem();
|
||||
lastFrameItem.setType("image_url");
|
||||
lastFrameItem.setRole("last_frame");
|
||||
ImageUrl lastImageUrl = new ImageUrl();
|
||||
lastImageUrl.setUrl(request.getLastUrl());
|
||||
lastFrameItem.setImageUrl(lastImageUrl);
|
||||
contentList.add(lastFrameItem);
|
||||
|
||||
ByteBodyReq body = newVideoBody(request, contentList);
|
||||
return submitOrderAndCreate(request, "image-first-last-frame", body);
|
||||
}
|
||||
|
||||
@PostMapping("/image-reference")
|
||||
@ApiOperation("图生视频-基于参考图")
|
||||
public AjaxResult imageReference(@RequestBody PortalVideoGenRequest request) {
|
||||
List<ContentItem> contentList;
|
||||
if (request.getContent() != null && !request.getContent().isEmpty()) {
|
||||
contentList = new ArrayList<>(request.getContent());
|
||||
ContentItem head = contentList.get(0);
|
||||
if (head == null || !"text".equals(head.getType()) || StringUtils.isEmpty(head.getText())) {
|
||||
return AjaxResult.error("请输入视频描述文本(首条须为 type=text,可含 [图片n]/[视频n]/[音频n] 占位)");
|
||||
}
|
||||
// 保留 text + 合法 reference_*(图/音/视频);允许“只有 text 没有参考素材”
|
||||
List<ContentItem> filtered = new ArrayList<>();
|
||||
filtered.add(head);
|
||||
for (int i = 1; i < contentList.size(); i++) {
|
||||
ContentItem it = contentList.get(i);
|
||||
if (isReferenceImageContentItem(it)
|
||||
|| isReferenceAudioContentItem(it)
|
||||
|| isReferenceVideoContentItem(it)) {
|
||||
filtered.add(it);
|
||||
}
|
||||
}
|
||||
contentList = filtered;
|
||||
|
||||
String firstRef = contentList.stream()
|
||||
.skip(1)
|
||||
.map(PortalVideoController::firstReferenceUrlFromItem)
|
||||
.filter(StringUtils::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
// 无参考图也允许,仅 text 提示词
|
||||
if (StringUtils.isNotEmpty(firstRef) && StringUtils.isEmpty(request.getReferenceUrl())) {
|
||||
request.setReferenceUrl(firstRef);
|
||||
}
|
||||
} else {
|
||||
if (StringUtils.isEmpty(request.getReferenceUrl())) {
|
||||
return AjaxResult.error("请上传参考图");
|
||||
}
|
||||
if (StringUtils.isEmpty(request.getText())) {
|
||||
return AjaxResult.error("请输入视频描述文本");
|
||||
}
|
||||
contentList = new ArrayList<>();
|
||||
ContentItem textItem = new ContentItem();
|
||||
textItem.setType("text");
|
||||
textItem.setText(request.getText());
|
||||
contentList.add(textItem);
|
||||
|
||||
ContentItem refItem = new ContentItem();
|
||||
refItem.setType("image_url");
|
||||
refItem.setRole("reference_image");
|
||||
ImageUrl refUrl = new ImageUrl();
|
||||
refUrl.setUrl(request.getReferenceUrl());
|
||||
refItem.setImageUrl(refUrl);
|
||||
contentList.add(refItem);
|
||||
}
|
||||
|
||||
ByteBodyReq body = newVideoBody(request, contentList);
|
||||
return submitOrderAndCreate(request, "image-reference", body);
|
||||
}
|
||||
|
||||
private static String firstReferenceUrlFromItem(ContentItem item) {
|
||||
if (isReferenceImageContentItem(item)) {
|
||||
return item.getImageUrl().getUrl();
|
||||
}
|
||||
if (isReferenceVideoContentItem(item)) {
|
||||
return item.getVideoUrl().getUrl();
|
||||
}
|
||||
if (isReferenceAudioContentItem(item)) {
|
||||
return item.getAudioUrl().getUrl();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isValidReferenceAssetOrHttpUrl(String raw) {
|
||||
if (StringUtils.isEmpty(raw)) {
|
||||
return false;
|
||||
}
|
||||
String url = raw.trim().toLowerCase();
|
||||
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("asset://");
|
||||
}
|
||||
|
||||
private static boolean isReferenceImageContentItem(ContentItem item) {
|
||||
if (item == null || !"image_url".equals(item.getType())) {
|
||||
return false;
|
||||
}
|
||||
if (!"reference_image".equals(item.getRole())) {
|
||||
return false;
|
||||
}
|
||||
ImageUrl iu = item.getImageUrl();
|
||||
if (iu == null || StringUtils.isEmpty(iu.getUrl())) {
|
||||
return false;
|
||||
}
|
||||
return isValidReferenceAssetOrHttpUrl(iu.getUrl());
|
||||
}
|
||||
|
||||
private static boolean isReferenceVideoContentItem(ContentItem item) {
|
||||
if (item == null || !"video_url".equals(item.getType())) {
|
||||
return false;
|
||||
}
|
||||
if (!"reference_video".equals(item.getRole())) {
|
||||
return false;
|
||||
}
|
||||
ImageUrl vu = item.getVideoUrl();
|
||||
if (vu == null || StringUtils.isEmpty(vu.getUrl())) {
|
||||
return false;
|
||||
}
|
||||
return isValidReferenceAssetOrHttpUrl(vu.getUrl());
|
||||
}
|
||||
|
||||
private static boolean isReferenceAudioContentItem(ContentItem item) {
|
||||
if (item == null || !"audio_url".equals(item.getType())) {
|
||||
return false;
|
||||
}
|
||||
if (!"reference_audio".equals(item.getRole())) {
|
||||
return false;
|
||||
}
|
||||
ImageUrl au = item.getAudioUrl();
|
||||
if (au == null || StringUtils.isEmpty(au.getUrl())) {
|
||||
return false;
|
||||
}
|
||||
return isValidReferenceAssetOrHttpUrl(au.getUrl());
|
||||
}
|
||||
|
||||
private List<ContentItem> buildTextAndFirstFrame(String text, String firstUrl) {
|
||||
List<ContentItem> contentList = new ArrayList<>();
|
||||
ContentItem textItem = new ContentItem();
|
||||
textItem.setType("text");
|
||||
textItem.setText(text);
|
||||
contentList.add(textItem);
|
||||
|
||||
ContentItem firstFrameItem = new ContentItem();
|
||||
firstFrameItem.setType("image_url");
|
||||
firstFrameItem.setRole("first_frame");
|
||||
ImageUrl firstImageUrl = new ImageUrl();
|
||||
firstImageUrl.setUrl(firstUrl);
|
||||
firstFrameItem.setImageUrl(firstImageUrl);
|
||||
contentList.add(firstFrameItem);
|
||||
return contentList;
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
@ApiOperation("门户视频生成可选参数(模型/比例/时长等,来自配置)")
|
||||
public AjaxResult videoParamOptions() {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("defaults", portalVideoProperties.getDefaults());
|
||||
data.put("models", portalVideoProperties.getModels());
|
||||
data.put("ratios", portalVideoProperties.getRatios());
|
||||
data.put("durations", portalVideoProperties.getDurations());
|
||||
data.put("resolutions", portalVideoProperties.getResolutions());
|
||||
return AjaxResult.success(data);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks")
|
||||
@ApiOperation("查询视频生成任务列表(本用户库表分页)")
|
||||
public TableDataInfo listMyVideoTasks(AiOrder aiOrder) {
|
||||
aiOrder.setUserId(SecurityUtils.getAiUserId());
|
||||
aiOrder.setType("21");
|
||||
startPage();
|
||||
List<AiOrder> list = aiOrderService.selectAiOrderList(aiOrder);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
@GetMapping("/volc-tasks")
|
||||
@ApiOperation("查询视频生成任务列表(火山平台,按本用户在库中的任务 id 过滤)")
|
||||
public AjaxResult listVolcTasks(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) throws Exception {
|
||||
Long uid = SecurityUtils.getAiUserId();
|
||||
String key = apiKey();
|
||||
String raw = byteService.listVideoGenerationTasks(pageNum, pageSize, key);
|
||||
String filtered = filterVolcTasksJsonForUser(raw, uid);
|
||||
return AjaxResult.success(OM.readTree(filtered));
|
||||
}
|
||||
|
||||
private String filterVolcTasksJsonForUser(String raw, Long userId) throws Exception {
|
||||
AiOrder query = new AiOrder();
|
||||
query.setUserId(userId);
|
||||
query.setType("21");
|
||||
List<AiOrder> mine = aiOrderService.selectAiOrderList(query);
|
||||
Set<String> allowed = mine.stream()
|
||||
.map(AiOrder::getResult)
|
||||
.filter(StringUtils::isNotEmpty)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
JsonNode root = OM.readTree(raw);
|
||||
if (!(root instanceof ObjectNode)) {
|
||||
return raw;
|
||||
}
|
||||
ArrayNode arr = null;
|
||||
String arrayKey = null;
|
||||
if (root.has("items") && root.get("items").isArray()) {
|
||||
arr = (ArrayNode) root.get("items");
|
||||
arrayKey = "items";
|
||||
} else if (root.has("data") && root.get("data").isArray()) {
|
||||
arr = (ArrayNode) root.get("data");
|
||||
arrayKey = "data";
|
||||
}
|
||||
if (arr == null) {
|
||||
return raw;
|
||||
}
|
||||
ArrayNode out = OM.createArrayNode();
|
||||
for (JsonNode n : arr) {
|
||||
String tid = n.has("id") ? n.get("id").asText() : null;
|
||||
if (tid != null && allowed.contains(tid)) {
|
||||
out.add(n);
|
||||
}
|
||||
}
|
||||
ObjectNode result = ((ObjectNode) root).deepCopy();
|
||||
result.set(arrayKey, out);
|
||||
result.put("filtered_total", out.size());
|
||||
return OM.writeValueAsString(result);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
@ApiOperation("查询单个视频生成任务(火山)")
|
||||
public AjaxResult getVolcTask(@PathVariable String taskId) throws Exception {
|
||||
Long uid = SecurityUtils.getAiUserId();
|
||||
AiOrder owned = aiOrderService.getAiOrderByPortalVideoTask(taskId);
|
||||
if (owned == null || !uid.equals(owned.getUserId())) {
|
||||
return AjaxResult.error("无权查看该任务");
|
||||
}
|
||||
String key = apiKey();
|
||||
ByteBodyRes byteBodyRes = byteService.uploadVideo(taskId, key);
|
||||
if ("succeeded".equals(byteBodyRes.getStatus())) {
|
||||
content contentObj = byteBodyRes.getContent();
|
||||
if (contentObj != null && StringUtils.isNotEmpty(contentObj.getVideo_url())) {
|
||||
String videoUrl = tencentCosUtil.uploadFileByUrl(contentObj.getVideo_url());
|
||||
if (videoUrl != null) {
|
||||
contentObj.setVideo_url(videoUrl);
|
||||
AiOrder aiOrder = new AiOrder();
|
||||
aiOrder.setId(owned.getId());
|
||||
aiOrder.setResult(videoUrl);
|
||||
aiOrderService.updateAiOrder(aiOrder);
|
||||
}
|
||||
}
|
||||
}
|
||||
return AjaxResult.success(byteBodyRes);
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@ApiOperation("删除或取消视频生成任务")
|
||||
public AjaxResult deleteOrCancelTask(@PathVariable String taskId) throws Exception {
|
||||
Long uid = SecurityUtils.getAiUserId();
|
||||
AiOrder owned = aiOrderService.getAiOrderByPortalVideoTask(taskId);
|
||||
if (owned == null || !uid.equals(owned.getUserId())) {
|
||||
return AjaxResult.error("无权操作该任务");
|
||||
}
|
||||
String key = apiKey();
|
||||
AjaxResult cancelRes = byteService.cancelVideoTask(taskId, key);
|
||||
if (cancelRes.isSuccess() && owned.getStatus() != null && owned.getStatus() == 0) {
|
||||
aiOrderService.orderFailure(owned);
|
||||
}
|
||||
return cancelRes;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,11 @@ public class ByteApiRequest {
|
|||
@ApiModelProperty(name = "标签字符串")
|
||||
private String tags;
|
||||
|
||||
@ApiModelProperty(name = "使用的模型")
|
||||
private String model;
|
||||
|
||||
@ApiModelProperty(name = "生成模式:text-to-video 或 image-to-video")
|
||||
private String mode = "text-to-video";
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.ruoyi.api.request;
|
||||
|
||||
import com.ruoyi.ai.domain.ContentItem;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 门户视频生成(火山 Seedance)请求体
|
||||
*/
|
||||
@Data
|
||||
public class PortalVideoGenRequest {
|
||||
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* 文生视频时可选:多段文本 + 参考图(与火山 content 一致);不传则仅使用 text 单行
|
||||
*/
|
||||
private List<ContentItem> content;
|
||||
|
||||
/** 默认与后台配置的视频计费类型一致 */
|
||||
private String functionType = "21";
|
||||
|
||||
private String model;
|
||||
|
||||
private Integer duration;
|
||||
|
||||
private String resolution;
|
||||
|
||||
private String ratio;
|
||||
|
||||
private String firstUrl;
|
||||
|
||||
private String lastUrl;
|
||||
|
||||
/** 图生视频-参考图模式 */
|
||||
private String referenceUrl;
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
package com.ruoyi.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 门户视频生成参数(模型、比例、时长、分辨率等从配置读取,不在代码中写死业务默认值)。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "portal.video")
|
||||
public class PortalVideoProperties {
|
||||
|
||||
private Defaults defaults = new Defaults();
|
||||
|
||||
private List<ModelOption> models = new ArrayList<>();
|
||||
|
||||
private List<String> ratios = new ArrayList<>();
|
||||
|
||||
private List<Integer> durations = new ArrayList<>();
|
||||
|
||||
private List<String> resolutions = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 与 ai_manager.type 一致,用于扣费与订单;库中需存在对应记录且 status=0、del_flag=0
|
||||
*/
|
||||
private String functionType = "21";
|
||||
|
||||
public String getFunctionType() {
|
||||
return functionType;
|
||||
}
|
||||
|
||||
public void setFunctionType(String functionType) {
|
||||
this.functionType = functionType != null ? functionType : "21";
|
||||
}
|
||||
|
||||
public Defaults getDefaults() {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public void setDefaults(Defaults defaults) {
|
||||
if (defaults != null) {
|
||||
this.defaults = defaults;
|
||||
}
|
||||
}
|
||||
|
||||
public List<ModelOption> getModels() {
|
||||
return models;
|
||||
}
|
||||
|
||||
public void setModels(List<ModelOption> models) {
|
||||
this.models = models != null ? models : new ArrayList<>();
|
||||
}
|
||||
|
||||
public List<String> getRatios() {
|
||||
return ratios;
|
||||
}
|
||||
|
||||
public void setRatios(List<String> ratios) {
|
||||
this.ratios = ratios != null ? ratios : new ArrayList<>();
|
||||
}
|
||||
|
||||
public List<Integer> getDurations() {
|
||||
return durations;
|
||||
}
|
||||
|
||||
public void setDurations(List<Integer> durations) {
|
||||
this.durations = durations != null ? durations : new ArrayList<>();
|
||||
}
|
||||
|
||||
public List<String> getResolutions() {
|
||||
return resolutions;
|
||||
}
|
||||
|
||||
public void setResolutions(List<String> resolutions) {
|
||||
this.resolutions = resolutions != null ? resolutions : new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* defaults.model 为空时,取 models 第一项的 value。
|
||||
*/
|
||||
public String resolveDefaultModelId() {
|
||||
if (defaults.getModel() != null && !defaults.getModel().isEmpty()) {
|
||||
return defaults.getModel();
|
||||
}
|
||||
if (!models.isEmpty() && models.get(0).getValue() != null) {
|
||||
return models.get(0).getValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class Defaults {
|
||||
private String model;
|
||||
private Integer duration;
|
||||
private String resolution;
|
||||
private String ratio;
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public Integer getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public void setDuration(Integer duration) {
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
public String getResolution() {
|
||||
return resolution;
|
||||
}
|
||||
|
||||
public void setResolution(String resolution) {
|
||||
this.resolution = resolution;
|
||||
}
|
||||
|
||||
public String getRatio() {
|
||||
return ratio;
|
||||
}
|
||||
|
||||
public void setRatio(String ratio) {
|
||||
this.ratio = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ModelOption {
|
||||
private String label;
|
||||
private String value;
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.ruoyi.web.controller.ai;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.ai.service.IAiUserService;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.constant.UserConstants;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.entity.SysDept;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.system.service.ISysDeptService;
|
||||
|
||||
/**
|
||||
* AI 业务侧部门管理(数据源与系统部门 sys_dept 一致,权限独立)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/ai/dept")
|
||||
public class AiDeptController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private ISysDeptService deptService;
|
||||
|
||||
@Autowired
|
||||
private IAiUserService aiUserService;
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ai:dept:list')")
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list(SysDept dept)
|
||||
{
|
||||
List<SysDept> depts = deptService.selectDeptList(dept);
|
||||
return success(depts);
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ai:dept:list')")
|
||||
@GetMapping("/list/exclude/{deptId}")
|
||||
public AjaxResult excludeChild(@PathVariable(value = "deptId", required = false) Long deptId)
|
||||
{
|
||||
List<SysDept> depts = deptService.selectDeptList(new SysDept());
|
||||
depts.removeIf(d -> d.getDeptId().intValue() == deptId || ArrayUtils.contains(StringUtils.split(d.getAncestors(), ","), deptId + ""));
|
||||
return success(depts);
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ai:dept:query')")
|
||||
@GetMapping(value = "/{deptId}")
|
||||
public AjaxResult getInfo(@PathVariable Long deptId)
|
||||
{
|
||||
deptService.checkDeptDataScope(deptId);
|
||||
return success(deptService.selectDeptById(deptId));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ai:dept:add')")
|
||||
@Log(title = "AI部门管理", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public AjaxResult add(@Validated @RequestBody SysDept dept)
|
||||
{
|
||||
if (!deptService.checkDeptNameUnique(dept))
|
||||
{
|
||||
return error("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在");
|
||||
}
|
||||
dept.setCreateBy(getUsername());
|
||||
return toAjax(deptService.insertDept(dept));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ai:dept:edit')")
|
||||
@Log(title = "AI部门管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public AjaxResult edit(@Validated @RequestBody SysDept dept)
|
||||
{
|
||||
Long deptId = dept.getDeptId();
|
||||
deptService.checkDeptDataScope(deptId);
|
||||
if (!deptService.checkDeptNameUnique(dept))
|
||||
{
|
||||
return error("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在");
|
||||
}
|
||||
else if (dept.getParentId().equals(deptId))
|
||||
{
|
||||
return error("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己");
|
||||
}
|
||||
else if (StringUtils.equals(UserConstants.DEPT_DISABLE, dept.getStatus()) && deptService.selectNormalChildrenDeptById(deptId) > 0)
|
||||
{
|
||||
return error("该部门包含未停用的子部门!");
|
||||
}
|
||||
dept.setUpdateBy(getUsername());
|
||||
return toAjax(deptService.updateDept(dept));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ai:dept:remove')")
|
||||
@Log(title = "AI部门管理", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{deptId}")
|
||||
public AjaxResult remove(@PathVariable Long deptId)
|
||||
{
|
||||
if (deptService.hasChildByDeptId(deptId))
|
||||
{
|
||||
return warn("存在下级部门,不允许删除");
|
||||
}
|
||||
if (deptService.checkDeptExistUser(deptId))
|
||||
{
|
||||
return warn("部门存在系统用户,不允许删除");
|
||||
}
|
||||
if (aiUserService.countAiUserByDeptId(deptId) > 0)
|
||||
{
|
||||
return warn("部门存在 AI 用户,不允许删除");
|
||||
}
|
||||
deptService.checkDeptDataScope(deptId);
|
||||
return toAjax(deptService.deleteDeptById(deptId));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,27 @@
|
|||
package com.ruoyi.web.controller.ai;
|
||||
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import com.ruoyi.ai.service.EmailVerifyService;
|
||||
import com.ruoyi.ai.service.IAiUserService;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.constant.BalanceChangerConstants;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import com.ruoyi.common.core.domain.model.RegisterAiUserBody;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.MessageUtils;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.poi.ExcelUtil;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.mail.MessagingException;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* ai-用户信息Controller
|
||||
|
|
@ -77,7 +71,6 @@ public class AiUserController extends BaseController {
|
|||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 新增ai-用户信息
|
||||
*/
|
||||
|
|
@ -100,6 +93,20 @@ public class AiUserController extends BaseController {
|
|||
return toAjax(aiUserService.updateAiUser(aiUser));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配归属部门(deptId 为空表示不归属任何部门)
|
||||
*/
|
||||
@ApiOperation("分配AI用户归属部门")
|
||||
@PreAuthorize("@ss.hasPermi('ai:user:edit')")
|
||||
@Log(title = "ai-用户信息", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/dept")
|
||||
public AjaxResult assignDept(@RequestBody AiUser aiUser) {
|
||||
if (aiUser.getId() == null) {
|
||||
return error("用户主键不能为空");
|
||||
}
|
||||
return toAjax(aiUserService.updateAiUserDept(aiUser.getId(), aiUser.getDeptId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态修改
|
||||
*/
|
||||
|
|
@ -123,9 +130,12 @@ public class AiUserController extends BaseController {
|
|||
aiUser.setUpdateBy(getUsername());
|
||||
// 获取原余额
|
||||
AiUser u = aiUserService.selectAiUserById(aiUser.getId());
|
||||
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
String orderNo = dateTime + uuid;
|
||||
// 计算变更余额
|
||||
BigDecimal amount = NumberUtil.sub(aiUser.getBalance(), u.getBalance());
|
||||
aiUserService.addUserBalance(aiUser.getId(), amount, BalanceChangerConstants.SYSTEM_OPERATION);
|
||||
aiUserService.addUserBalance(orderNo, aiUser.getId(), amount, BalanceChangerConstants.SYSTEM_OPERATION);
|
||||
return toAjax(1);
|
||||
}
|
||||
|
||||
|
|
@ -151,8 +161,6 @@ public class AiUserController extends BaseController {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// @GetMapping("/addUserBalance")
|
||||
// public AjaxResult addUserBalance(BigDecimal amount) {
|
||||
// aiUserService.addUserBalance(SecurityUtils.getAiUserId(), amount, BalanceChangerConstants.RECHARGE);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import com.ruoyi.common.annotation.Anonymous;
|
|||
import com.ruoyi.common.config.RuoYiConfig;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.exception.base.BaseException;
|
||||
import com.ruoyi.common.utils.AwsS3Util;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.TencentCosUtil;
|
||||
import com.ruoyi.common.utils.file.FileUploadUtils;
|
||||
|
|
@ -36,7 +35,6 @@ public class CommonController {
|
|||
private static final Logger log = LoggerFactory.getLogger(CommonController.class);
|
||||
private final ServerConfig serverConfig;
|
||||
private final TencentCosUtil tencentCosUtil;
|
||||
private final AwsS3Util awsS3Util;
|
||||
|
||||
/**
|
||||
* 通用下载请求
|
||||
|
|
@ -158,7 +156,7 @@ public class CommonController {
|
|||
}
|
||||
|
||||
/**
|
||||
* AWS上传请求(单个)
|
||||
* 腾讯云COS上传请求(单个)
|
||||
*/
|
||||
@ApiOperation("图片上传接口")
|
||||
@PostMapping("/aws/upload")
|
||||
|
|
@ -166,7 +164,7 @@ public class CommonController {
|
|||
AjaxResult ajax = AjaxResult.success();
|
||||
String uploadUrl;
|
||||
try {
|
||||
uploadUrl = awsS3Util.uploadMultipartFile(file, true);
|
||||
uploadUrl = tencentCosUtil.uploadMultipartFile(file, true);
|
||||
} catch (Exception e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ spring:
|
|||
druid:
|
||||
# 主库数据源
|
||||
master:
|
||||
url: jdbc:mysql://xxxxx:xxxxx/byteai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
url: jdbc:mysql://database-1.cvs822qoc391.ap-southeast-1.rds.amazonaws.com:3306/byteai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: root
|
||||
password: xxxxxx
|
||||
password: mkMReisAKl6I7rVqEY90
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
|
||||
# 初始连接数
|
||||
|
|
@ -41,8 +41,8 @@ spring:
|
|||
allow:
|
||||
url-pattern: /druid/*
|
||||
# 控制台管理用户名和密码
|
||||
login-username:
|
||||
login-password:
|
||||
login-username: ruoyi
|
||||
login-password: cvs822qoc391
|
||||
filter:
|
||||
stat:
|
||||
enabled: true
|
||||
|
|
@ -54,3 +54,4 @@ spring:
|
|||
config:
|
||||
multi-statement-allow: true
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,12 +47,16 @@ user:
|
|||
|
||||
# Spring配置
|
||||
spring:
|
||||
# 邮件发送配置
|
||||
# 邮件发送配置
|
||||
mail:
|
||||
host: smtp.qq.com # QQ邮箱SMTP服务器(163邮箱:smtp.163.com;Gmail:smtp.gmail.com)
|
||||
# host: mailcow-self.undressing.name # QQ邮箱SMTP服务器(163邮箱:smtp.163.com;Gmail:smtp.gmail.com)
|
||||
# port: 465 # SMTP端口(SSL加密端口:465,非加密:587,优先用587)
|
||||
# username: undressing@mail.undressing.name # 你的邮箱地址
|
||||
# password: aRtHEN39 # 邮箱授权码(不是登录密码!)
|
||||
host: smtpdm-ap-southeast-1.aliyun.com # QQ邮箱SMTP服务器(163邮箱:smtp.163.com;Gmail:smtp.gmail.com)
|
||||
port: 465 # SMTP端口(SSL加密端口:465,非加密:587,优先用587)
|
||||
username: # 你的邮箱地址
|
||||
password: # 邮箱授权码(不是登录密码!)
|
||||
username: undressing@undressing.name # 你的邮箱地址
|
||||
password: 1284GOvkho # 邮箱授权码(不是登录密码!)
|
||||
default-encoding: UTF-8 # 编码格式
|
||||
protocol: smtp
|
||||
# SSL/TLS配置(可选,根据邮箱要求)
|
||||
|
|
@ -89,15 +93,17 @@ spring:
|
|||
# redis 配置
|
||||
redis:
|
||||
# 地址
|
||||
host: localhost
|
||||
host: master.redis.chguac.apse1.cache.amazonaws.com
|
||||
# 端口,默认为6379
|
||||
port: 6379
|
||||
# 数据库索引
|
||||
database: 2
|
||||
# 密码
|
||||
password:
|
||||
user: root
|
||||
password: mkMReisAKl6I7rVqEY90
|
||||
# 连接超时时间
|
||||
timeout: 10s
|
||||
ssl: true
|
||||
lettuce:
|
||||
pool:
|
||||
# 连接池中的最小空闲连接
|
||||
|
|
@ -108,7 +114,6 @@ spring:
|
|||
max-active: 8
|
||||
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-wait: -1ms
|
||||
|
||||
# 自定义验证码配置
|
||||
verify:
|
||||
code:
|
||||
|
|
@ -204,42 +209,109 @@ google:
|
|||
redirect-uri:
|
||||
|
||||
tencentCos:
|
||||
accessKey:
|
||||
secretKey:
|
||||
endpoint:
|
||||
bucketName:
|
||||
domain:
|
||||
accessKey: AKIDBE3dzBdLsHYfZLwKVSFArLchZDerrfHf
|
||||
secretKey: EDyUmsnX2IJ5f0oRn1QdeQ0TmrtqgQ1c
|
||||
endpoint: ap-guangzhou
|
||||
bucketName: seedance-1331490964
|
||||
domain: https://seedance-1331490964.cos.ap-guangzhou.myqcloud.com
|
||||
|
||||
aws:
|
||||
accessKey: AKIAYVMHEVDDZQGE3HVX
|
||||
secretKey: B9nxdferMhdRuxzoKeQam/NxiVvIhI7lSru6VfwG
|
||||
endpoint: ap-southeast-1
|
||||
bucketName: di-image
|
||||
domain: https://images.iqyjsnwv.com/
|
||||
# aws配置已替换为腾讯云COS,请在环境变量或配置文件中设置腾讯云凭证
|
||||
# aws:
|
||||
# accessKey: AKIAYVMHEVDDZQGE3HVX
|
||||
# secretKey: B9nxdferMhdRuxzoKeQam/NxiVvIhI7lSru6VfwG
|
||||
# endpoint: ap-southeast-1
|
||||
# bucketName: di-image
|
||||
# domain: https://images.iqyjsnwv.com/
|
||||
|
||||
byteapi:
|
||||
url: https://ark.ap-southeast.bytepluses.com/api/v3
|
||||
apiKey: 327d2815-2516-44c2-9e32-2dc50bf7afd7
|
||||
callBackUrl: www.google.com
|
||||
apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97
|
||||
callBackUrl: https://undressing.top
|
||||
|
||||
# 火山引擎 Ark API (Seedance 2.0)
|
||||
volcengine:
|
||||
ark:
|
||||
baseUrl: https://ark.cn-beijing.volces.com
|
||||
apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97
|
||||
callbackUrl: https://undressing.top/api/ai/volcCallback
|
||||
|
||||
# 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举
|
||||
portal:
|
||||
video:
|
||||
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist,请插入对应 type 或改此处与库一致
|
||||
function-type: "21"
|
||||
defaults:
|
||||
model: ep-20260326165811-dlkth
|
||||
duration: 4
|
||||
resolution: 720p
|
||||
ratio: "3:4"
|
||||
models:
|
||||
- label: Seedance 2.0
|
||||
value: ep-20260326165811-dlkth
|
||||
- label: Seedance 2.0 Fast
|
||||
value: ep-20260326170056-dkj9m
|
||||
ratios:
|
||||
- "16:9"
|
||||
- "9:16"
|
||||
- "3:4"
|
||||
- "1:1"
|
||||
- "4:3"
|
||||
durations:
|
||||
- 4
|
||||
- 5
|
||||
- 6
|
||||
- 8
|
||||
- 10
|
||||
- 11
|
||||
- 12
|
||||
- 13
|
||||
- 14
|
||||
- 15
|
||||
resolutions:
|
||||
- "720p"
|
||||
- "1080p"
|
||||
|
||||
jinsha:
|
||||
url: https://api.jinshapay.xyz
|
||||
appId: 1763617360
|
||||
secret: a201e7969af5045dcd62d203b26121ae
|
||||
notifyUrl: www.google.com
|
||||
returnUrl: www.google.com
|
||||
notifyUrl: https://undressing.top
|
||||
returnUrl: https://undressing.top
|
||||
|
||||
kada:
|
||||
url: https://rapi.openkada.xyz
|
||||
appId: c70f1719017e290354017d1c101d0cc288d06ceb
|
||||
secret: ME2VRe6tWH6weK/NAUJA5lhmewHkB23rA6CdWlrHrAs+/E/E3j3eG3io/GCHbQKqMMurfTNrBj/R4Yy84UziM5YJheiKFKbsWQc5xRoE46E3/0EYy4ZjbK9jhwGyHS+C
|
||||
notifyUrl: www.google.com
|
||||
returnUrl: www.google.com
|
||||
|
||||
appId: d1743d48fb8fc24f38b7268015cf800e3b49f0fd
|
||||
secret: b0CH/+tVEsz+1j2mfBzd9Kgu6UylJxr0056TwTbkKfHWw9UW/6TaQyQHv+teBnGbqWy5ObaLUMvnrs9adpymebEqjI3ipNpJa7YPQbMYm0VGuYUEgeM+fjakhWuYx2XEVzmjdIvvfhNsfr2YHTmDUzwIKPbp/OJvfG9KhSPMzpw=
|
||||
notifyUrl: https://undressing.top
|
||||
returnUrl: https://undressing.top
|
||||
|
||||
yuzhou:
|
||||
url: https://pay.joinus6688.cc
|
||||
url: https://api.fast-vip.store
|
||||
appId: PM20251211091945
|
||||
publicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiTl8fg6eM6uUJqxAjGtzskt+ESIgferomy6uUtjRx4yhu6I4cVBgaw9ErJq9KMNQpMVl44GEese6PRDmNPdvXBktI/skpCfyNvT+1LqYm69Hh+rFre2Ve+0XIVoln0H3EGNUHL/KPOCm2tYXLXlZ3r02z+AQeS3rxNhE4jr32oQIDAQAB
|
||||
secretKey: MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAL7U/f7yb9Z9j56dauCUb0B/I0ONAcZDK+TtOnAgLEjV4qrirYHYuCumxYbPFvt6qYggjbBpphFhihbWzf8IPS7iD6VXoSX1T1iAlWFL1ZaBscqQSvPxFGtgpTBiRFS3KkZV70WswcCL770OCkY8+DJpdyk9OkD6vBa0TYU1wxJFAgMBAAECgYBpF+t5iBJHUYbSl2bQn25VWq8U+IbNpRh7TposPculoQTfj052f9+NSp7liw7hF8Bdk2/0g3pNgCYIRevUU7k9MEIKqHCiOWkyavtsfqGYI37PZ4/0uMzB5eibTqKTEkcyskSJ9GxrL4uGKgTGNc213i3VOcZZ4xEfvuDQCHF8gQJBAPATuhQeFNNAIE9TkGiESHFGChSZZgzp1xfGrAt8BwidBSe+r9duAcGJSeNJatxneeu0w6NuwQ6iq9ztnqtoG90CQQDLfSHrOTloHSa+DdBc2SFQWa/P4K2Tznb8Y5ng8L/t+a9sYvGjWOln0R3Bq9TrImm0AjWnq/saaMg2nYD1wr2JAkA36XA5xTO2a0XbE6wbG0u/zb8FQyCIO2GTsPpahl0g/Wi48+kB9CXGjBHANFYF1LeJVIUHqACgRvRdtJ1ycAGlAkEAqASVWiTw2p+fWrQLRG7gS/kR6uIIUI/cvT78UrhWsYdFqof0Hz0N0/PdzwkzkEbk4oYkiWK+viqgjj/0uHfoiQJAQhPYVVLHD7xiJApc/Aga6g0OFF5O7zy8KTsq+KTXqRlJREBH5nirSponHwYalEbUvtQrVs+Z4BCBEGCU8m2GEw==
|
||||
redirectUrl: www.google.com
|
||||
callbackUrl: www.google.com
|
||||
publicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiTl8fg6eM6uUJqxAjGtzskt+ESIgferomy6uUtjRx4yhu6I4cVBgaw9ErJq9KMNQpMVl44GEese6PRDmNPdvXBktI/skpCfyNvT+1LqYm69Hh+rFre2Ve+0XIVoln0H3EGNUHL/KPOCm2tYXLXlZ3r02z+AQeS3rxNhE4jr32oQIDAQAB
|
||||
secretKey: MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOyqpe98vNWLL/1ja4WnUkCGiHAVnNJtlw7c9bdacCc7xgGSlXIq1bRQWXMjLZeSC31BUFcrIMk5eMr1AWUTYnVOmAJ4IsLZCUMQOBU9S6BJdqnaus4K6Kul35lrb/Zt6ju6fY1acL7q3NWJGdeRtmYaGXZbF+RH6UbA6aH4vkHBAgMBAAECgYAEh+8EbveOYZCuGdpil82w9KUVpe5Hj5JQANOMpU+RdWPLiX7xDX2jqv2kFmEeiPeNbXk7AAABJjzoKtO84lz/n0Vp8hwqIgeMdmLZBBTsJhXgOfgnvwjb/1Ev2Yjg3KGp7nKvkyPpoU9e0W8+PR5yTclylV0bCYqsP260BFLnIQJBAPmqUzQLQN739dKoO3uVpB9qU3exZOv+zlTQZj2C0cXt3/+JzJs78DBieN9+b3ZLq307namt8QAUVV+QwRoNPxECQQDyq+QoAakpi1tQoGfYUecucDcZf8GDPdEkCGhGoIq/FkzL0gGub45Rz0oXzAzBME6DcobfVVippcUB54pueTexAkBxDTtX2bCqRkW9+gfVUlFbGF5rWJyGcH8l0Kg7Oj2bDrfbkp5fvKhqgGyTZ0E5o9InhxNBfk4e5xYxi+6kyVLBAkBcPahA6LizOOxhzkcKu78jMLZQ8/XLfCDWEHgKeJWkosZYJyBKfM7dG+zu9LnYaRM+9bZ8h8Vm3sLuwWMmMN9RAkBdOSqC71e9248Bmw70zX7x6ZmszGQ64toA6mhhcdYb4wAlDtmm0OJjJRmpyXdhgdIHF3i7vNe7sXpPfNKFc1Yf
|
||||
redirectUrl: https://undressing.top
|
||||
callbackUrl: https://undressing.top
|
||||
vm:
|
||||
url: http://payment-api.togame.top
|
||||
mchNo: M1768983012
|
||||
appId: 697089e4f41a4f456f159408
|
||||
secret: 120tzr4snoq11yus8la9gx7cbutw1uore4pervckvqmsswrt1hl9qkd0ug5r6twwv94jex03ajpsmsky2za4x1kghd2l54z4nn7t5fcy4gewsvwjjxrce5q1f7u2yeqj
|
||||
notifyUrl: https://undressing.top
|
||||
# 支付方式,固定为BUZHI_VM(国际VM卡支付)
|
||||
wayCode: BUZHI_VM
|
||||
# 货币代码,默认USD
|
||||
currency: USD
|
||||
|
||||
# 汇率服务配置
|
||||
exchange-rate:
|
||||
enabled: true
|
||||
apikey: 0bed27315c87475f8dd6a0792a632cc5
|
||||
api-url: https://api.currencyfreaks.com/v2.0/rates/latest
|
||||
# base 货币(API返回的基准货币,通常为USD)
|
||||
base-currency: USD
|
||||
# 备用汇率(当API调用失败时使用)
|
||||
fallback-rate-jinsha: 90
|
||||
fallback-rate-kada: 60
|
||||
# PHP比索的备用汇率(当API调用失败时使用,默认使用JinSha的汇率)
|
||||
fallback-rate-php: 90
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@
|
|||
<artifactId>commons-io</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Apache Commons Codec for Base64 encoding/decoding -->
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- excel工具 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
|
|
|
|||
|
|
@ -117,12 +117,19 @@ public class AiUser extends BaseEntity {
|
|||
*/
|
||||
private String country;
|
||||
|
||||
/** 归属部门ID(sys_dept.dept_id) */
|
||||
private Long deptId;
|
||||
|
||||
/**
|
||||
* 上级用户昵称
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String superiorName;
|
||||
|
||||
/** 归属部门名称 */
|
||||
@TableField(exist = false)
|
||||
private String deptName;
|
||||
|
||||
/**
|
||||
* 上级用户ID
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ public class SysDept extends BaseEntity
|
|||
|
||||
/** 父部门名称 */
|
||||
private String parentName;
|
||||
|
||||
/** Byte API Key */
|
||||
private String byteApiKey;
|
||||
|
||||
/** 子部门 */
|
||||
private List<SysDept> children = new ArrayList<SysDept>();
|
||||
|
|
@ -171,6 +174,16 @@ public class SysDept extends BaseEntity
|
|||
this.parentName = parentName;
|
||||
}
|
||||
|
||||
public String getByteApiKey()
|
||||
{
|
||||
return byteApiKey;
|
||||
}
|
||||
|
||||
public void setByteApiKey(String byteApiKey)
|
||||
{
|
||||
this.byteApiKey = byteApiKey;
|
||||
}
|
||||
|
||||
public List<SysDept> getChildren()
|
||||
{
|
||||
return children;
|
||||
|
|
@ -192,6 +205,7 @@ public class SysDept extends BaseEntity
|
|||
.append("leader", getLeader())
|
||||
.append("phone", getPhone())
|
||||
.append("email", getEmail())
|
||||
.append("byteApiKey", getByteApiKey())
|
||||
.append("status", getStatus())
|
||||
.append("delFlag", getDelFlag())
|
||||
.append("createBy", getCreateBy())
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ import org.springframework.beans.factory.annotation.Value;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
|
|
@ -33,22 +39,32 @@ public class TencentCosUtil {
|
|||
private String domain;
|
||||
|
||||
|
||||
//文件上传
|
||||
public String upload(MultipartFile file) {
|
||||
/**
|
||||
* 上传MultipartFile到腾讯云COS,返回文件访问地址
|
||||
* 与AwsS3Util.uploadMultipartFile方法接口兼容
|
||||
*/
|
||||
public String upload(MultipartFile file) throws Exception {
|
||||
return uploadMultipartFile(file, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传MultipartFile到腾讯云COS,返回文件访问地址
|
||||
*
|
||||
* @param file 前端上传的MultipartFile
|
||||
* @param isPublic 是否公开访问(当前实现中忽略,使用domain配置)
|
||||
* @return 文件访问地址(URL字符串)
|
||||
*/
|
||||
public String uploadMultipartFile(MultipartFile file, boolean isPublic) throws Exception {
|
||||
if (file.isEmpty()) {
|
||||
throw new IllegalArgumentException("上传文件不能为空");
|
||||
}
|
||||
|
||||
// 3 生成 cos 客户端。
|
||||
COSClient cosClient = createCosClient();
|
||||
|
||||
// 存储桶的命名格式为 BucketName-APPID,此处填写的存储桶名称必须为此格式
|
||||
// 对象键(Key)是对象在存储桶中的唯一标识。 998u-09iu-09i-333
|
||||
//在文件名称前面添加uuid值
|
||||
String key = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8) + "_"
|
||||
+ file.getOriginalFilename();
|
||||
//对上传文件分组,根据当前日期 /2022/11/11
|
||||
String dateTime = new DateTime().toString("yyyy/MM/dd");
|
||||
key = dateTime + "/" + key;
|
||||
// 生成唯一文件键,格式与AWS一致:yyyy/MM/dd/uuid_filename
|
||||
String key = generateCosKey(file.getOriginalFilename());
|
||||
|
||||
try {
|
||||
//获取上传文件输入流
|
||||
InputStream inputStream = file.getInputStream();
|
||||
ObjectMetadata objectMetadata = new ObjectMetadata();
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(
|
||||
|
|
@ -56,17 +72,41 @@ public class TencentCosUtil {
|
|||
key,
|
||||
inputStream,
|
||||
objectMetadata);
|
||||
// 高级接口会返回一个异步结果Upload
|
||||
|
||||
PutObjectResult putObjectResult = cosClient.putObject(putObjectRequest);
|
||||
|
||||
//返回上传文件路径
|
||||
//https://ggkt-atguigu-1310644373.cos.ap-beijing.myqcloud.com/01.jpg
|
||||
String url = domain + "/" + key;
|
||||
// 返回COS文件访问地址
|
||||
String url = domain + (domain.endsWith("/") ? "" : "/") + key;
|
||||
return url;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException("上传文件到COS失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
cosClient.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过URL下载文件并上传到COS
|
||||
* 与AwsS3Util.uploadFileByUrl方法接口兼容
|
||||
*/
|
||||
public String uploadFileByUrl(String fileUrl) throws Exception {
|
||||
return uploadFileByUrl(fileUrl, true);
|
||||
}
|
||||
|
||||
public String uploadFileByUrl(String fileUrl, boolean isPublic) throws Exception {
|
||||
if (fileUrl == null || fileUrl.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("文件下载链接不能为空");
|
||||
}
|
||||
|
||||
Path tempPath = downloadFileToTemp(fileUrl);
|
||||
try {
|
||||
// 使用临时文件上传
|
||||
MultipartFile multipartFile = createMultipartFileFromPath(tempPath, extractFileNameFromUrl(fileUrl));
|
||||
return uploadMultipartFile(multipartFile, isPublic);
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -78,7 +118,107 @@ public class TencentCosUtil {
|
|||
ClientConfig clientConfig = new ClientConfig(region);
|
||||
// 这里建议设置使用 https 协议
|
||||
clientConfig.setHttpProtocol(HttpProtocol.https);
|
||||
clientConfig.setConnectionTimeout(30 * 1000); // 连接超时30秒
|
||||
clientConfig.setSocketTimeout(60 * 1000); // 读取超时60秒
|
||||
//1.3 生成cos客户端
|
||||
return new COSClient(credentials, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成COS文件键,与AWS保持一致的命名格式
|
||||
*/
|
||||
private String generateCosKey(String originalFileName) {
|
||||
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||
String dateTime = new DateTime().toString("yyyy/MM/dd");
|
||||
return dateTime + "/" + uuid + "_" + originalFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件到临时路径
|
||||
*/
|
||||
private Path downloadFileToTemp(String fileUrl) throws Exception {
|
||||
String suffix = getFileSuffixFromUrl(fileUrl);
|
||||
Path tempPath = Files.createTempFile("url-upload-", suffix);
|
||||
|
||||
HttpURLConnection connection = null;
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
|
||||
try {
|
||||
URL url = new URL(fileUrl);
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(5000);
|
||||
connection.setReadTimeout(10000);
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode < 200 || responseCode >= 300) {
|
||||
throw new RuntimeException("文件下载失败,状态码:" + responseCode);
|
||||
}
|
||||
|
||||
in = connection.getInputStream();
|
||||
out = Files.newOutputStream(tempPath);
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} finally {
|
||||
if (out != null) {
|
||||
try { out.close(); } catch (IOException e) { e.printStackTrace(); }
|
||||
}
|
||||
if (in != null) {
|
||||
try { in.close(); } catch (IOException e) { e.printStackTrace(); }
|
||||
}
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
private String extractFileNameFromUrl(String fileUrl) {
|
||||
String fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1);
|
||||
if (fileName.contains("?")) {
|
||||
fileName = fileName.split("\\?")[0];
|
||||
}
|
||||
return fileName.isEmpty() ? "default_file" : fileName;
|
||||
}
|
||||
|
||||
private String getFileSuffixFromUrl(String fileUrl) {
|
||||
String fileName = extractFileNameFromUrl(fileUrl);
|
||||
if (fileName.contains(".")) {
|
||||
return fileName.substring(fileName.lastIndexOf("."));
|
||||
}
|
||||
return ".tmp";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Path转换为MultipartFile(简单实现,用于uploadFileByUrl)
|
||||
*/
|
||||
private MultipartFile createMultipartFileFromPath(Path path, String originalFilename) throws IOException {
|
||||
byte[] bytes = Files.readAllBytes(path);
|
||||
return new MultipartFile() {
|
||||
@Override
|
||||
public String getName() { return "file"; }
|
||||
@Override
|
||||
public String getOriginalFilename() { return originalFilename; }
|
||||
@Override
|
||||
public String getContentType() { return null; }
|
||||
@Override
|
||||
public boolean isEmpty() { return bytes.length == 0; }
|
||||
@Override
|
||||
public long getSize() { return bytes.length; }
|
||||
@Override
|
||||
public byte[] getBytes() throws IOException { return bytes; }
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException { return Files.newInputStream(path); }
|
||||
@Override
|
||||
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
|
||||
Files.copy(path, dest.toPath());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
package com.ruoyi.common.utils.sign;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.codec.binary.Base64;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -16,25 +21,29 @@ import java.util.Collections;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class RSAUtils {
|
||||
|
||||
// RSA最⼤加密明⽂⼤⼩
|
||||
// RSA最大加密明文大小
|
||||
private static final int MAX_ENCRYPT_BLOCK = 117;
|
||||
// 不仅可以使⽤DSA算法,同样也可以使⽤RSA算法做数字签名
|
||||
// 不仅可以使用DSA算法,同样也可以使用RSA算法做数字签名
|
||||
private static final String KEY_ALGORITHM = "RSA";
|
||||
|
||||
public static String encryptByPrivateKey(String str, String privateKey) throws Exception {
|
||||
public static String encryptByPrivateKey(String str, String privateKey)
|
||||
throws InvalidKeySpecException, NoSuchAlgorithmException, javax.crypto.NoSuchPaddingException, java.security.InvalidKeyException,
|
||||
javax.crypto.IllegalBlockSizeException, javax.crypto.BadPaddingException, UnsupportedEncodingException {
|
||||
// base64编码的公钥
|
||||
byte[] keyBytes = decryptBASE64(privateKey);
|
||||
RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance(KEY_ALGORITHM)
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
// RSA加密
|
||||
Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, priKey);
|
||||
byte[] data = str.getBytes("UTF-8");
|
||||
// 加密时超过117字节就报错。为此采⽤分段加密的办法来加密
|
||||
// 加密时超过117字节就报错。为此采用分段加密的办法来加密
|
||||
byte[] enBytes = null;
|
||||
for (int i = 0; i < data.length; i += MAX_ENCRYPT_BLOCK) {
|
||||
// 注意要使⽤2的倍数,否则会出现加密后的内容再解密时为乱码
|
||||
// 注意要使用2的倍数,否则会出现加密后的内容再解密时为乱码
|
||||
byte[] doFinal = cipher.doFinal(ArrayUtils.subarray(data, i, i + MAX_ENCRYPT_BLOCK));
|
||||
enBytes = ArrayUtils.addAll(enBytes, doFinal);
|
||||
}
|
||||
|
|
@ -43,15 +52,22 @@ public class RSAUtils {
|
|||
}
|
||||
|
||||
private static String encryptBASE64(byte[] data) {
|
||||
return new String(Base64.encode(data));
|
||||
return new String(Base64.encodeBase64(data));
|
||||
}
|
||||
|
||||
private static byte[] decryptBASE64(String data) {
|
||||
return Base64.decode(data);
|
||||
return Base64.decodeBase64(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify signature
|
||||
*
|
||||
* @param params
|
||||
* @return
|
||||
*/
|
||||
public static boolean verifySign(JSONObject params, String publickey) {
|
||||
String platSign = params.getStr("signature"); // sign
|
||||
log.info("signature:" + platSign);
|
||||
List<String> paramNameList = new ArrayList<>();
|
||||
for (String key : params.keySet()) {
|
||||
if (!"signature".equals(key)) {
|
||||
|
|
@ -64,13 +80,14 @@ public class RSAUtils {
|
|||
String name = paramNameList.get(i);
|
||||
stringBuilder.append(params.getStr(name));
|
||||
}
|
||||
log.info("keys:" + stringBuilder);
|
||||
String decryptSign = "";
|
||||
try {
|
||||
decryptSign = publicDecrypt(platSign, getPublicKey(publickey)
|
||||
);
|
||||
decryptSign = publicDecrypt(platSign, getPublicKey(publickey));
|
||||
} catch (Exception e) {
|
||||
System.out.println(e.toString());
|
||||
log.error("Error decrypting signature", e);
|
||||
}
|
||||
log.info("decryptSign:" + decryptSign);
|
||||
if (!stringBuilder.toString().equalsIgnoreCase(decryptSign)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -89,11 +106,12 @@ public class RSAUtils {
|
|||
stringBuilder.append(createMap.get(key)); // 拼接参数
|
||||
}
|
||||
String keyStr = stringBuilder.toString(); // 得到待加密的字符串
|
||||
log.info("keyStr:" + keyStr);
|
||||
String signedStr = "";
|
||||
try {
|
||||
signedStr = privateEncrypt(keyStr, getPrivateKey(MCH_PRIVATE_KEY)); // 私钥加密
|
||||
} catch (Exception e) {
|
||||
System.out.println(e.toString());
|
||||
log.error("Error encrypting signature", e);
|
||||
}
|
||||
return signedStr;
|
||||
}
|
||||
|
|
@ -110,47 +128,70 @@ public class RSAUtils {
|
|||
stringBuilder.append(createMap.get(key)); // 拼接参数
|
||||
}
|
||||
String keyStr = stringBuilder.toString(); // 得到待加密的字符串
|
||||
log.info("keyStr:" + keyStr);
|
||||
String signedStr = "";
|
||||
try {
|
||||
signedStr = privateEncrypt(keyStr, getPrivateKey(MCH_PRIVATE_KEY)); // 私钥加密
|
||||
} catch (Exception e) {
|
||||
System.out.println(e.toString());
|
||||
log.error("Error encrypting signature", e);
|
||||
}
|
||||
return signedStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* private key encryption
|
||||
*
|
||||
* @param data
|
||||
* @param privateKey
|
||||
* @return
|
||||
*/
|
||||
public static String privateEncrypt(String data, RSAPrivateKey privateKey) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("RSA");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
|
||||
return Base64.encode(rsaSplitCodec(cipher, Cipher.ENCRYPT_MODE, data.getBytes("UTF-8"), privateKey.getModulus().bitLength()));
|
||||
return Base64.encodeBase64String(rsaSplitCodec(cipher, Cipher.ENCRYPT_MODE, data.getBytes("UTF-8"), privateKey.getModulus().bitLength()));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Exception encountered while encry pting string [" + data + "]", e);
|
||||
throw new RuntimeException("Exception encountered while encrypting string [" + data + "]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* public key decryption
|
||||
*/
|
||||
public static String publicDecrypt(String data, RSAPublicKey publicKey) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("RSA");
|
||||
cipher.init(Cipher.DECRYPT_MODE, publicKey);
|
||||
return new String(rsaSplitCodec(cipher, Cipher.DECRYPT_MODE, Base64.decode(data), publicKey.getModulus().bitLength()), "UTF-8");
|
||||
return new String(rsaSplitCodec(cipher, Cipher.DECRYPT_MODE, Base64.decodeBase64(data), publicKey.getModulus().bitLength()), "UTF-8");
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("An exception was encountered whil e decrypting the string[" + data + "]", e);
|
||||
throw new RuntimeException("An exception was encountered while decrypting the string[" + data + "]", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static RSAPrivateKey getPrivateKey(String privateKey) throws Exception {
|
||||
/**
|
||||
* get private key
|
||||
*
|
||||
* @param privateKey key string (base64 encoded)
|
||||
* @throws Exception
|
||||
*/
|
||||
public static RSAPrivateKey getPrivateKey(String privateKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
//Get the private key object through the PKCS#8 encoded Key instruction
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(Base64.decode(privateKey));
|
||||
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(Base64.decodeBase64(privateKey));
|
||||
RSAPrivateKey key = (RSAPrivateKey) keyFactory.generatePrivate(pkcs8KeySpec);
|
||||
return key;
|
||||
}
|
||||
|
||||
public static RSAPublicKey getPublicKey(String publicKey) throws Exception {
|
||||
/**
|
||||
* get the public key
|
||||
*
|
||||
* @param publicKey key string (base64 encoded)
|
||||
* @throws Exception
|
||||
*/
|
||||
public static RSAPublicKey getPublicKey(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
//Get the public key object through the X509 encoded Key instruction
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(Base64.decode(publicKey));
|
||||
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(Base64.decodeBase64(publicKey));
|
||||
RSAPublicKey key = (RSAPublicKey) keyFactory.generatePublic(x509KeySpec);
|
||||
return key;
|
||||
}
|
||||
|
|
@ -179,7 +220,7 @@ public class RSAUtils {
|
|||
offSet = i * maxBlock;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("An exception occurred when encryp ting and decrypting data whose threshold is [" + maxBlock + "]", e);
|
||||
throw new RuntimeException("An exception occurred when encrypting and decrypting data whose threshold is [" + maxBlock + "]", e);
|
||||
}
|
||||
byte[] resultDatas = out.toByteArray();
|
||||
IOUtils.closeQuietly(out);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import org.apache.commons.lang3.builder.ToStringBuilder;
|
||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 余额使用记录对象 ai_balance_change_record
|
||||
|
|
@ -24,13 +22,23 @@ public class AiBalanceChangeRecord extends BaseEntity {
|
|||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 主键ID */
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 删除标志(0代表存在 2代表删除) */
|
||||
/**
|
||||
* 删除标志(0代表存在 2代表删除)
|
||||
*/
|
||||
private String delFlag;
|
||||
|
||||
/**
|
||||
* 关联订单号
|
||||
*/
|
||||
@Excel(name = "关联订单号")
|
||||
private String orderNo;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
|
|
@ -42,19 +50,27 @@ public class AiBalanceChangeRecord extends BaseEntity {
|
|||
@TableField(exist = false)
|
||||
private String uuid;
|
||||
|
||||
/** 操作类型:0-充值 1-返佣 2-充值赠送 3-体验金赠送 4-体验金回收 5-图生图 6-一键换脸 7-快捷生图 8-快捷生视频 9-退款 10-系统操作 */
|
||||
/**
|
||||
* 操作类型:0-充值 1-返佣 2-充值赠送 3-体验金赠送 4-体验金回收 5-图生图 6-一键换脸 7-快捷生图 8-快捷生视频 9-退款 10-系统操作
|
||||
*/
|
||||
@Excel(name = "操作类型")
|
||||
private Integer type;
|
||||
|
||||
/** 变更金额 */
|
||||
/**
|
||||
* 变更金额
|
||||
*/
|
||||
@Excel(name = "变更金额")
|
||||
private BigDecimal changeAmount;
|
||||
|
||||
/** 变更后金额 */
|
||||
/**
|
||||
* 变更后金额
|
||||
*/
|
||||
@Excel(name = "变更后金额")
|
||||
private BigDecimal resultAmount;
|
||||
|
||||
/** 用户昵称 */
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Excel(name = "用户昵称")
|
||||
private String nickname;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,28 @@ public class AiOrder extends BaseEntity {
|
|||
@Excel(name = "是否置顶:N-否 Y-是")
|
||||
private String isTop;
|
||||
|
||||
/** 生成模式:text-to-video 或 image-to-video */
|
||||
@Excel(name = "生成模式")
|
||||
private String mode;
|
||||
|
||||
/** 视频时长(秒) */
|
||||
@Excel(name = "视频时长")
|
||||
private Integer duration;
|
||||
|
||||
/** 分辨率(如 720p, 1080p) */
|
||||
@Excel(name = "分辨率")
|
||||
private String resolution;
|
||||
|
||||
/** 宽高比(如 16:9, 9:16) */
|
||||
@Excel(name = "宽高比")
|
||||
private String ratio;
|
||||
|
||||
/** 火山/方舟模型 endpoint */
|
||||
private String model;
|
||||
|
||||
/** 视频类订单:本次提交的完整参数 JSON(提示词、模型、时长等) */
|
||||
private String videoParams;
|
||||
|
||||
/** 首帧图片 */
|
||||
private String img1;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import lombok.Data;
|
||||
import org.apache.commons.lang3.builder.ToStringBuilder;
|
||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
/**
|
||||
* 返佣记录对象 ai_rebate_record
|
||||
*
|
||||
|
|
@ -23,22 +22,38 @@ public class AiRebateRecord extends BaseEntity {
|
|||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 主键ID */
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 删除标志(0代表存在 2代表删除) */
|
||||
/**
|
||||
* 关联订单号
|
||||
*/
|
||||
@Excel(name = "关联订单号")
|
||||
private String orderNo;
|
||||
|
||||
/**
|
||||
* 删除标志(0代表存在 2代表删除)
|
||||
*/
|
||||
private String delFlag;
|
||||
|
||||
/** 上级ID */
|
||||
/**
|
||||
* 上级ID
|
||||
*/
|
||||
@Excel(name = "上级ID")
|
||||
private Long superiorId;
|
||||
|
||||
/** 下级ID */
|
||||
/**
|
||||
* 下级ID
|
||||
*/
|
||||
@Excel(name = "下级ID")
|
||||
private Long subordinateId;
|
||||
|
||||
/** 返佣金额 */
|
||||
/**
|
||||
* 返佣金额
|
||||
*/
|
||||
@Excel(name = "返佣金额")
|
||||
private BigDecimal amount;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
import org.apache.commons.lang3.builder.ToStringBuilder;
|
||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
/**
|
||||
* 体验金领取记录对象 ai_sample_amount_record
|
||||
*
|
||||
|
|
@ -25,22 +24,38 @@ public class AiSampleAmountRecord extends BaseEntity {
|
|||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 主键ID */
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 删除标志(0代表存在 2代表删除) */
|
||||
/**
|
||||
* 关联订单号
|
||||
*/
|
||||
@Excel(name = "关联订单号")
|
||||
private String orderNo;
|
||||
|
||||
/**
|
||||
* 删除标志(0代表存在 2代表删除)
|
||||
*/
|
||||
private String delFlag;
|
||||
|
||||
/** 用户ID */
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@Excel(name = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
/** 体验金额 */
|
||||
/**
|
||||
* 体验金额
|
||||
*/
|
||||
@Excel(name = "体验金额")
|
||||
private BigDecimal sampleAmount;
|
||||
|
||||
/** 回收时间 */
|
||||
/**
|
||||
* 回收时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Excel(name = "回收时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date recycleTime;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ public class AiTemplate extends BaseEntity {
|
|||
@Excel(name = "模版图片URL")
|
||||
private String imageUrl;
|
||||
|
||||
/** AI类型 */
|
||||
@Excel(name = "AI类型")
|
||||
private Long aiId;
|
||||
|
||||
/** 状态:0-禁用,1-启用 */
|
||||
@Excel(name = "状态:0-禁用,1-启用")
|
||||
private Integer status;
|
||||
|
|
|
|||
|
|
@ -30,5 +30,10 @@ public class ByteBodyReq {
|
|||
@JsonProperty("content")
|
||||
private List<ContentItem> content;
|
||||
|
||||
private Integer duration;
|
||||
private String resolution;
|
||||
private String ratio;
|
||||
private Integer seed;
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ public class ContentItem {
|
|||
@JsonProperty("image_url")
|
||||
private ImageUrl imageUrl;
|
||||
|
||||
@JsonProperty("video_url")
|
||||
private ImageUrl videoUrl;
|
||||
|
||||
@JsonProperty("audio_url")
|
||||
private ImageUrl audioUrl;
|
||||
|
||||
@JsonProperty("role")
|
||||
private String role;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ public class JinShaBodyReq {
|
|||
|
||||
private String notify_url;
|
||||
|
||||
private String return_url;
|
||||
|
||||
private String sign;
|
||||
private String cardname;
|
||||
private String cardno;
|
||||
|
||||
/**
|
||||
* 档位ID
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* VM支付回调请求
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Data
|
||||
public class VmCallBackReq {
|
||||
|
||||
/**
|
||||
* 商户号
|
||||
*/
|
||||
private String mchNo;
|
||||
|
||||
/**
|
||||
* 应用ID
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 商户订单号
|
||||
*/
|
||||
private String mchOrderNo;
|
||||
|
||||
/**
|
||||
* 支付订单号
|
||||
*/
|
||||
private String payOrderId;
|
||||
|
||||
/**
|
||||
* 支付方式
|
||||
*/
|
||||
private String wayCode;
|
||||
|
||||
/**
|
||||
* 支付金额,单位分
|
||||
*/
|
||||
private Integer amount;
|
||||
|
||||
/**
|
||||
* 货币代码
|
||||
*/
|
||||
private String currency;
|
||||
|
||||
/**
|
||||
* 支付接口编码
|
||||
*/
|
||||
private String ifCode;
|
||||
|
||||
/**
|
||||
* 商品标题
|
||||
*/
|
||||
private String subject;
|
||||
|
||||
/**
|
||||
* 商品描述
|
||||
*/
|
||||
private String body;
|
||||
|
||||
/**
|
||||
* 订单状态:0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
|
||||
* 文档字段名:state
|
||||
*/
|
||||
private Integer state;
|
||||
|
||||
/**
|
||||
* 客户端IP(可选)
|
||||
*/
|
||||
private String clientIp;
|
||||
|
||||
/**
|
||||
* 渠道订单号(可选)
|
||||
*/
|
||||
private String channelOrderNo;
|
||||
|
||||
/**
|
||||
* 渠道错误码(可选)
|
||||
*/
|
||||
private String errCode;
|
||||
|
||||
/**
|
||||
* 渠道错误描述(可选)
|
||||
*/
|
||||
private String errMsg;
|
||||
|
||||
/**
|
||||
* 扩展参数,回调时会原样返回
|
||||
*/
|
||||
private String extParam;
|
||||
|
||||
/**
|
||||
* 订单创建时间,13位时间戳
|
||||
*/
|
||||
private Long createdAt;
|
||||
|
||||
/**
|
||||
* 订单支付成功时间,13位时间戳(可选)
|
||||
*/
|
||||
private Long successTime;
|
||||
|
||||
/**
|
||||
* 通知请求时间,13位时间戳。文档字段名:reqTime
|
||||
*/
|
||||
private Long reqTime;
|
||||
|
||||
/**
|
||||
* 签名值
|
||||
*/
|
||||
private String sign;
|
||||
|
||||
/**
|
||||
* 签名类型(文档回调示例无此字段,不参与签名)
|
||||
*/
|
||||
private String signType;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* VM卡信息
|
||||
* 当wayCode为BUZHI_VM时,需要在extParam中传入此信息的JSON字符串
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Data
|
||||
public class VmCardInfo {
|
||||
|
||||
/**
|
||||
* 信用卡卡号
|
||||
*/
|
||||
private String number;
|
||||
|
||||
/**
|
||||
* CVC
|
||||
*/
|
||||
private String cvc;
|
||||
|
||||
/**
|
||||
* 过期年(四位数字,如:2027)
|
||||
*/
|
||||
private String expYear;
|
||||
|
||||
/**
|
||||
* 过期月(两位数,如:11)
|
||||
*/
|
||||
private String expMonth;
|
||||
|
||||
/**
|
||||
* 信用卡持有者电子邮件
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 持卡人名字
|
||||
*/
|
||||
private String firstName;
|
||||
|
||||
/**
|
||||
* 持卡人姓氏
|
||||
*/
|
||||
private String lastName;
|
||||
|
||||
/**
|
||||
* 国家: 双字母 ISO 国家代码(两位字母)
|
||||
*/
|
||||
private String country;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* VM支付请求
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Data
|
||||
public class VmPayReq {
|
||||
|
||||
/**
|
||||
* 档位ID
|
||||
*/
|
||||
private Long gearId;
|
||||
|
||||
/**
|
||||
* VM卡信息(可选,如果为null则需要在extParam中传入)
|
||||
*/
|
||||
private VmCardInfo vmCardInfo;
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* VM统一下单请求
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Data
|
||||
public class VmUnifiedOrderReq {
|
||||
|
||||
/**
|
||||
* 商户号
|
||||
*/
|
||||
private String mchNo;
|
||||
|
||||
/**
|
||||
* 应用ID
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 商户订单号
|
||||
*/
|
||||
private String mchOrderNo;
|
||||
|
||||
/**
|
||||
* 支付方式,如:BUZHI_VM(国际VM卡支付)
|
||||
*/
|
||||
private String wayCode;
|
||||
|
||||
/**
|
||||
* 支付金额,单位分
|
||||
*/
|
||||
private Integer amount;
|
||||
|
||||
/**
|
||||
* 货币代码,三位货币代码,人民币:cny 美元:USD
|
||||
*/
|
||||
private String currency;
|
||||
|
||||
/**
|
||||
* 客户端IPV4地址
|
||||
*/
|
||||
private String clientIp;
|
||||
|
||||
/**
|
||||
* 商品标题
|
||||
*/
|
||||
private String subject;
|
||||
|
||||
/**
|
||||
* 商品描述
|
||||
*/
|
||||
private String body;
|
||||
|
||||
/**
|
||||
* 异步通知地址
|
||||
*/
|
||||
private String notifyUrl;
|
||||
|
||||
/**
|
||||
* 订单失效时间,单位秒,默认2小时
|
||||
*/
|
||||
private Integer expiredTime;
|
||||
|
||||
/**
|
||||
* 特定渠道发起的额外参数,json格式字符串
|
||||
*/
|
||||
private String channelExtra;
|
||||
|
||||
/**
|
||||
* 商户扩展参数,回调时会原样返回
|
||||
* 当wayCode为BUZHI_VM时,需要传入VM卡信息的JSON字符串
|
||||
*/
|
||||
private String extParam;
|
||||
|
||||
/**
|
||||
* 请求接口时间,13位时间戳
|
||||
*/
|
||||
private Long reqTime;
|
||||
|
||||
/**
|
||||
* 接口版本号,固定:1.0
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 签名值
|
||||
*/
|
||||
private String sign;
|
||||
|
||||
/**
|
||||
* 签名类型,目前只支持MD5方式
|
||||
*/
|
||||
private String signType;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* VM统一下单响应
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class VmUnifiedOrderRes {
|
||||
|
||||
/**
|
||||
* 响应码,0表示成功
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 响应消息
|
||||
*/
|
||||
private String msg;
|
||||
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
private VmUnifiedOrderData data;
|
||||
|
||||
/**
|
||||
* 响应签名(API 返回)
|
||||
*/
|
||||
private String sign;
|
||||
|
||||
@Data
|
||||
public static class VmUnifiedOrderData {
|
||||
/**
|
||||
* 支付订单号(必填)
|
||||
*/
|
||||
private String payOrderId;
|
||||
|
||||
/**
|
||||
* 商户订单号(必填)
|
||||
*/
|
||||
private String mchOrderNo;
|
||||
|
||||
/**
|
||||
* 订单状态(必填)
|
||||
* 0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
|
||||
*/
|
||||
private Integer orderState;
|
||||
|
||||
/**
|
||||
* 支付数据类型(必填)
|
||||
* payUrl-跳转链接的方式
|
||||
* form-表单方式
|
||||
* wxapp-微信支付参数(微信公众号,小程序,app支付时)
|
||||
* aliapp-支付宝app支付参数
|
||||
* ysfapp-云闪付app支付参数
|
||||
* codeUrl-二维码地址
|
||||
* codeImgUrl-二维码图片地址
|
||||
* none-空支付参数
|
||||
*/
|
||||
private String payDataType;
|
||||
|
||||
/**
|
||||
* 支付数据(可选)
|
||||
* 发起支付用到的支付参数,根据payDataType不同而不同
|
||||
* 如果payDataType为payUrl,则此字段为跳转链接
|
||||
*/
|
||||
private String payData;
|
||||
|
||||
/**
|
||||
* 渠道错误码(可选)
|
||||
*/
|
||||
private String errCode;
|
||||
|
||||
/**
|
||||
* 渠道错误描述(可选)
|
||||
*/
|
||||
private String errMsg;
|
||||
|
||||
// 以下字段为兼容旧版本保留
|
||||
/**
|
||||
* 支付链接(兼容字段,优先使用payData)
|
||||
*/
|
||||
private String payUrl;
|
||||
|
||||
/**
|
||||
* 订单号(兼容字段)
|
||||
*/
|
||||
private String orderId;
|
||||
|
||||
/**
|
||||
* 支付参数(兼容字段)
|
||||
*/
|
||||
private String payParams;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,5 +22,8 @@ public interface AiOrderMapper extends BaseMapper<AiOrder> {
|
|||
|
||||
AiOrder getAiOrderByResult(@Param("result") String result);
|
||||
|
||||
/** 门户视频:按火山任务 id 查单(result 可能已被替换为成品 URL,故兼查 video_params.volcTaskId) */
|
||||
AiOrder getAiOrderByPortalVideoTask(@Param("taskId") String taskId);
|
||||
|
||||
BigDecimal getSumAmountByUserId(@Param("userId") String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import java.util.List;
|
|||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* ai-用户信息Mapper接口
|
||||
|
|
@ -22,4 +23,9 @@ public interface AiUserMapper extends BaseMapper<AiUser> {
|
|||
|
||||
AiUser selectAiUserById(Long id);
|
||||
AiUser getUserInfo(Long id);
|
||||
|
||||
int countAiUserByDeptId(@Param("deptId") Long deptId);
|
||||
|
||||
@Update("update ai_user set dept_id = #{deptId}, update_time = sysdate() where id = #{userId}")
|
||||
int updateAiUserDeptId(@Param("userId") Long userId, @Param("deptId") Long deptId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,5 +79,7 @@ public interface IAiOrderService {
|
|||
|
||||
AiOrder getAiOrderByResult(String result);
|
||||
|
||||
AiOrder getAiOrderByPortalVideoTask(String taskId);
|
||||
|
||||
BigDecimal getSumAmountByUserId(String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,9 +87,9 @@ public interface IAiUserService {
|
|||
|
||||
void updateEmail(String email, String code);
|
||||
|
||||
void addUserBalance(Long userId, BigDecimal amount, int recharge);
|
||||
void addUserBalance(String orderNo, Long userId, BigDecimal amount, int recharge);
|
||||
|
||||
void addUserBalance(Long userId, BigDecimal amount, int recharge, String remark);
|
||||
void addUserBalance(String orderNo, Long userId, BigDecimal amount, int recharge, String remark);
|
||||
|
||||
AiUser getUserByEmail(String email);
|
||||
|
||||
|
|
@ -99,9 +99,19 @@ public interface IAiUserService {
|
|||
|
||||
int updatePassword(AiUser aiUser);
|
||||
|
||||
/**
|
||||
* 部门下 AI 用户数量(用于删除部门前校验)
|
||||
*/
|
||||
int countAiUserByDeptId(Long deptId);
|
||||
|
||||
/**
|
||||
* 分配 AI 用户归属部门,deptId 为空则清空
|
||||
*/
|
||||
int updateAiUserDept(Long userId, Long deptId);
|
||||
|
||||
public void deductSampleAmount(String userId);
|
||||
|
||||
void handleRebate(Long userId, BigDecimal rechargeAmount);
|
||||
void handleRebate(String orderNo, Long userId, BigDecimal rechargeAmount);
|
||||
|
||||
String getRebateConfig();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package com.ruoyi.ai.service;
|
||||
|
||||
/**
|
||||
* 门户用户火山方舟 API Key:取自所属二级部门 {@code sys_dept.byte_api_key},带 Redis 缓存。
|
||||
*/
|
||||
public interface IByteDeptApiKeyService {
|
||||
|
||||
/**
|
||||
* Redis 键:{userId}_byte_api_key,优先读缓存,再读库;均无有效 key 则抛出业务异常。
|
||||
*/
|
||||
String resolveVolcApiKey(Long aiUserId);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.ruoyi.ai.service;
|
|||
|
||||
import com.ruoyi.ai.domain.ByteBodyReq;
|
||||
import com.ruoyi.ai.domain.ByteBodyRes;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
|
||||
public interface IByteService {
|
||||
|
||||
|
|
@ -16,12 +17,31 @@ public interface IByteService {
|
|||
ByteBodyRes imgToImg(ByteBodyReq req) throws Exception;
|
||||
|
||||
/**
|
||||
* 首尾帧图生视频
|
||||
* 首尾帧图生视频(使用全局配置的 Ark API Key)
|
||||
*/
|
||||
ByteBodyRes imgToVideo(ByteBodyReq req) throws Exception;
|
||||
|
||||
/**
|
||||
* 视频生成任务创建(指定 Ark API Key,门户按部门密钥调用)
|
||||
*/
|
||||
ByteBodyRes imgToVideo(ByteBodyReq req, String arkApiKey) throws Exception;
|
||||
|
||||
/**
|
||||
* 下载视频
|
||||
*/
|
||||
ByteBodyRes uploadVideo(String id) throws Exception;
|
||||
|
||||
ByteBodyRes uploadVideo(String id, String arkApiKey) throws Exception;
|
||||
|
||||
/**
|
||||
* 取消视频生成任务
|
||||
*/
|
||||
AjaxResult cancelVideoTask(String id) throws Exception;
|
||||
|
||||
AjaxResult cancelVideoTask(String id, String arkApiKey) throws Exception;
|
||||
|
||||
/**
|
||||
* GET 查询视频生成任务列表(火山 list 文档),返回原始 JSON 字符串
|
||||
*/
|
||||
String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package com.ruoyi.ai.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 汇率服务接口
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
public interface IExchangeRateService {
|
||||
|
||||
/**
|
||||
* 获取汇率
|
||||
*
|
||||
* @param fromCurrency 源货币代码(如:CNY)
|
||||
* @param toCurrency 目标货币代码(如:PHP)
|
||||
* @return 汇率值
|
||||
* @throws Exception 获取汇率失败时抛出异常
|
||||
*/
|
||||
BigDecimal getExchangeRate(String fromCurrency, String toCurrency) throws Exception;
|
||||
|
||||
/**
|
||||
* 转换金额
|
||||
*
|
||||
* @param amount 原始金额
|
||||
* @param fromCurrency 源货币代码(如:CNY)
|
||||
* @param toCurrency 目标货币代码(如:PHP)
|
||||
* @return 转换后的金额
|
||||
* @throws Exception 获取汇率失败时抛出异常
|
||||
*/
|
||||
BigDecimal convertAmount(BigDecimal amount, String fromCurrency, String toCurrency) throws Exception;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ public interface IJinShaService {
|
|||
|
||||
/**
|
||||
* 支付
|
||||
* @param gearId 档位ID
|
||||
*/
|
||||
PayResVO jinShaPay(Long gearId) throws Exception;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.ruoyi.ai.service;
|
||||
|
||||
import com.ruoyi.ai.domain.PayResVO;
|
||||
import com.ruoyi.ai.domain.VmCallBackReq;
|
||||
|
||||
/**
|
||||
* VM支付服务接口
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
public interface IVmService {
|
||||
|
||||
/**
|
||||
* VM支付
|
||||
*
|
||||
* @param gearId 档位ID
|
||||
* @param vmCardInfo VM卡信息(可选,如果为null则需要在extParam中传入)
|
||||
* @param clientIp 客户端IP地址
|
||||
* @return 支付结果
|
||||
* @throws Exception 支付异常
|
||||
*/
|
||||
PayResVO vmPay(Long gearId, com.ruoyi.ai.domain.VmCardInfo vmCardInfo, String clientIp) throws Exception;
|
||||
|
||||
/**
|
||||
* VM支付回调。文档要求返回字符串 "success" 表示成功,非 success 表示失败,支付中心将再次通知。
|
||||
*
|
||||
* @param req 回调请求
|
||||
* @return "success" 表示处理成功,其他字符串表示失败
|
||||
*/
|
||||
String vmCallBack(VmCallBackReq req);
|
||||
}
|
||||
|
|
@ -124,6 +124,8 @@ public class AiManagerServiceImpl implements IAiManagerService {
|
|||
public AiManager selectAiManagerByType(String aiType) {
|
||||
QueryWrapper<AiManager> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("type", aiType);
|
||||
queryWrapper.eq("del_flag", "0");
|
||||
queryWrapper.eq("status", 0);
|
||||
return aiManagerMapper.selectOne(queryWrapper);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ai.domain.AiManager;
|
||||
import com.ruoyi.ai.domain.AiOrder;
|
||||
import com.ruoyi.ai.domain.AiStatistics;
|
||||
import com.ruoyi.ai.mapper.AiOrderMapper;
|
||||
import com.ruoyi.ai.service.IAiManagerService;
|
||||
import com.ruoyi.ai.service.IAiOrderService;
|
||||
import com.ruoyi.ai.service.IAiStatisticsService;
|
||||
import com.ruoyi.ai.service.IAiUserService;
|
||||
import com.ruoyi.common.constant.BalanceChangerConstants;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
|
|
@ -27,14 +22,17 @@ import com.ruoyi.common.utils.MessageUtils;
|
|||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.ai.mapper.AiOrderMapper;
|
||||
import com.ruoyi.ai.domain.AiOrder;
|
||||
import com.ruoyi.ai.service.IAiOrderService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 订单管理Service业务层处理
|
||||
*
|
||||
*
|
||||
* @author shi
|
||||
* @date 2025-11-13
|
||||
*/
|
||||
|
|
@ -56,7 +54,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
|
||||
/**
|
||||
* 查询订单管理
|
||||
*
|
||||
*
|
||||
* @param id 订单管理主键
|
||||
* @return 订单管理
|
||||
*/
|
||||
|
|
@ -67,7 +65,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
|
||||
/**
|
||||
* 查询订单管理列表
|
||||
*
|
||||
*
|
||||
* @param aiOrder 订单管理
|
||||
* @return 订单管理
|
||||
*/
|
||||
|
|
@ -91,7 +89,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
|
||||
/**
|
||||
* 新增订单管理
|
||||
*
|
||||
*
|
||||
* @param aiOrder 订单管理
|
||||
* @return 结果
|
||||
*/
|
||||
|
|
@ -105,7 +103,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
|
||||
/**
|
||||
* 修改订单管理
|
||||
*
|
||||
*
|
||||
* @param aiOrder 订单管理
|
||||
* @return 结果
|
||||
*/
|
||||
|
|
@ -118,30 +116,29 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
|
||||
/**
|
||||
* 批量删除订单管理
|
||||
*
|
||||
*
|
||||
* @param ids 需要删除的订单管理主键
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
public int deleteAiOrderByIds(Long[] ids)
|
||||
{
|
||||
public int deleteAiOrderByIds(Long[] ids) {
|
||||
return aiOrderMapper.deleteAiOrderByIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除订单管理信息
|
||||
*
|
||||
*
|
||||
* @param id 订单管理主键
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
public int deleteAiOrderById(Long id)
|
||||
{
|
||||
public int deleteAiOrderById(Long id) {
|
||||
return aiOrderMapper.deleteAiOrderById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成订单
|
||||
*
|
||||
* @param aiType 对应的AI类型
|
||||
* @return
|
||||
*/
|
||||
|
|
@ -150,7 +147,9 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
public AiOrder getAiOrder(String aiType) {
|
||||
AiManager aiManager = aiManagerService.selectAiManagerByType(aiType);
|
||||
if (aiManager == null) {
|
||||
throw new ServiceException("Corresponding functionType does not exist", HttpStatus.BAD_REQUEST);
|
||||
throw new ServiceException(
|
||||
"未找到可用的功能类型:请在「AI管理」中新增 type=" + aiType + " 且状态为正常的记录,或执行 sql/seed_ai_manager_type_21.sql 初始化",
|
||||
HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
// 判断用户余额是否足够
|
||||
AiUser aiUser = aiUserService.selectAiUserById(SecurityUtils.getAiUserId());
|
||||
|
|
@ -170,7 +169,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
aiOrder.setSource(aiUser.getSource());
|
||||
aiOrderMapper.insert(aiOrder);
|
||||
// 执行余额变更
|
||||
aiUserService.addUserBalance(SecurityUtils.getAiUserId(), NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType));
|
||||
aiUserService.addUserBalance(orderno, SecurityUtils.getAiUserId(), NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType));
|
||||
return aiOrder;
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +180,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
String remark = MessageUtils.message("order.number.generation.failed", aiOrder.getOrderNum());
|
||||
aiOrder.setRemark(remark);
|
||||
aiOrderMapper.updateById(aiOrder);
|
||||
aiUserService.addUserBalance(SecurityUtils.getAiUserId(), aiOrder.getAmount(), BalanceChangerConstants.REFUND, remark);
|
||||
aiUserService.addUserBalance(aiOrder.getOrderNum(), SecurityUtils.getAiUserId(), aiOrder.getAmount(), BalanceChangerConstants.REFUND, remark);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -191,7 +190,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
aiOrderMapper.updateById(aiOrder);
|
||||
AiStatistics aiStatistics = new AiStatistics();
|
||||
aiStatistics.setSource(aiOrder.getSource());
|
||||
aiStatistics.setGenerateCount(1l);
|
||||
aiStatistics.setGenerateCount(1L);
|
||||
// 新增生成数量
|
||||
aiStatisticsService.saveOrUpdateData(aiStatistics);
|
||||
}
|
||||
|
|
@ -204,6 +203,11 @@ public class AiOrderServiceImpl implements IAiOrderService {
|
|||
return aiOrderMapper.selectOne(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiOrder getAiOrderByPortalVideoTask(String taskId) {
|
||||
return aiOrderMapper.getAiOrderByPortalVideoTask(taskId);
|
||||
}
|
||||
|
||||
public int getChangerType(String aiType) {
|
||||
switch (aiType) {
|
||||
case "11":
|
||||
|
|
|
|||
|
|
@ -1,29 +1,24 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ai.domain.*;
|
||||
import com.ruoyi.ai.mapper.AiRechargeMapper;
|
||||
import com.ruoyi.ai.service.*;
|
||||
import com.ruoyi.common.constant.BalanceChangerConstants;
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ai.domain.*;
|
||||
import com.ruoyi.ai.domain.enums.AiConfigEnum;
|
||||
import com.ruoyi.ai.service.*;
|
||||
import com.ruoyi.common.constant.BalanceChangerConstants;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.system.domain.SysConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.ai.mapper.AiRechargeMapper;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 充值管理Service业务层处理
|
||||
*
|
||||
|
|
@ -84,6 +79,10 @@ public class AiRechargeServiceImpl implements IAiRechargeService {
|
|||
public void addRecharge(String orderNo) {
|
||||
// 设置到账时间
|
||||
AiRecharge aiRecharge = this.getAiRechargeByOrderNum(orderNo);
|
||||
if (aiRecharge == null) {
|
||||
log.error("支付回调订单不存在,orderNo: {}", orderNo);
|
||||
return;
|
||||
}
|
||||
Date creditedTime = aiRecharge.getCreditedTime();
|
||||
if (creditedTime != null) {
|
||||
log.error("支付回调重复 {}", orderNo);
|
||||
|
|
@ -107,12 +106,10 @@ public class AiRechargeServiceImpl implements IAiRechargeService {
|
|||
aiRecharge.setGiftAmount(amount);
|
||||
|
||||
|
||||
|
||||
|
||||
// 新增用户余额
|
||||
aiUserService.addUserBalance(aiRecharge.getUserId(), amount, BalanceChangerConstants.RECHARGE);
|
||||
aiUserService.addUserBalance(orderNo, aiRecharge.getUserId(), amount, BalanceChangerConstants.RECHARGE);
|
||||
// 处理返佣 (根据充值金额计算返佣)
|
||||
aiUserService.handleRebate(aiRecharge.getUserId(), aiRechargeGiftGear.getRechargeAmount());
|
||||
aiUserService.handleRebate(orderNo, aiRecharge.getUserId(), aiRechargeGiftGear.getRechargeAmount());
|
||||
// 新增充值统计
|
||||
AiStatistics aiStatistics = new AiStatistics();
|
||||
aiStatistics.setSource(aiRecharge.getSource());
|
||||
|
|
@ -120,14 +117,14 @@ public class AiRechargeServiceImpl implements IAiRechargeService {
|
|||
statisticsService.saveOrUpdateData(aiStatistics);
|
||||
|
||||
|
||||
// 处理赠送金额
|
||||
// 处理赠送金额
|
||||
if (isFristRecharge && aiRecharge.getGearId() != null) {
|
||||
// 查询活动是否过期
|
||||
boolean isAvailable = aiRechargeGiftService.isActivityAvailable(aiRechargeGiftGear.getRechargeId());
|
||||
if (isAvailable) {
|
||||
aiRecharge.setGiveAmount(aiRechargeGiftGear.getGiveAmount());
|
||||
// 新增用户赠送余额
|
||||
aiUserService.addUserBalance(aiRecharge.getUserId(), aiRechargeGiftGear.getGiveAmount(), BalanceChangerConstants.RECHARGE_BONUS);
|
||||
aiUserService.addUserBalance(orderNo, aiRecharge.getUserId(), aiRechargeGiftGear.getGiveAmount(), BalanceChangerConstants.RECHARGE_BONUS);
|
||||
// 新增充值赠送记录
|
||||
AiRechargeGiftRecord aiRechargeGiftRecord = new AiRechargeGiftRecord();
|
||||
aiRechargeGiftRecord.setOrderNum(orderNo);
|
||||
|
|
|
|||
|
|
@ -9,20 +9,17 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||
import com.ruoyi.ai.domain.*;
|
||||
import com.ruoyi.ai.domain.enums.AiConfigEnum;
|
||||
import com.ruoyi.ai.mapper.AiRechargeMapper;
|
||||
import com.ruoyi.ai.mapper.AiSampleAmountMapper;
|
||||
import com.ruoyi.ai.mapper.AiSampleAmountRecordMapper;
|
||||
import com.ruoyi.ai.mapper.AiUserMapper;
|
||||
import com.ruoyi.ai.service.*;
|
||||
import com.ruoyi.common.constant.BalanceChangerConstants;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import com.ruoyi.ai.mapper.AiUserMapper;
|
||||
import com.ruoyi.common.core.domain.model.LoginAiUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginAiUserBody;
|
||||
import com.ruoyi.common.core.domain.model.RegisterAiUserBody;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.*;
|
||||
import com.ruoyi.common.utils.ip.IpUtils;
|
||||
import com.ruoyi.common.utils.uuid.UUID;
|
||||
import com.ruoyi.system.domain.SysConfig;
|
||||
import com.ruoyi.system.mapper.SysConfigMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -30,7 +27,6 @@ import org.springframework.stereotype.Service;
|
|||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
|
@ -70,7 +66,6 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
private AiRechargeMapper aiRechargeMapper;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 查询ai-用户信息
|
||||
*
|
||||
|
|
@ -137,6 +132,19 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
return aiUserMapper.updateById(aiUser);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countAiUserByDeptId(Long deptId) {
|
||||
if (deptId == null) {
|
||||
return 0;
|
||||
}
|
||||
return aiUserMapper.countAiUserByDeptId(deptId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateAiUserDept(Long userId, Long deptId) {
|
||||
return aiUserMapper.updateAiUserDeptId(userId, deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除ai-用户信息
|
||||
*
|
||||
|
|
@ -208,7 +216,7 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
// 新增访客统计
|
||||
AiStatistics aiStatistics = new AiStatistics();
|
||||
aiStatistics.setSource(registerAiUserBody.getSource());
|
||||
aiStatistics.setAddUserCount(1l);
|
||||
aiStatistics.setAddUserCount(1L);
|
||||
aiStatisticsService.saveOrUpdateData(aiStatistics);
|
||||
return aiUser;
|
||||
}
|
||||
|
|
@ -352,13 +360,13 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void addUserBalance(Long userId, BigDecimal amount, int recharge) {
|
||||
addUserBalance(userId, amount, recharge, null);
|
||||
public void addUserBalance(String orderNo, Long userId, BigDecimal amount, int recharge) {
|
||||
addUserBalance(orderNo, userId, amount, recharge, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void addUserBalance(Long userId, BigDecimal amount, int recharge, String remark) {
|
||||
public void addUserBalance(String orderNo, Long userId, BigDecimal amount, int recharge, String remark) {
|
||||
// 新增余额使用记录
|
||||
AiUser aiUser = aiUserMapper.selectById(userId);
|
||||
AiBalanceChangeRecord balanceChangeRecord = new AiBalanceChangeRecord();
|
||||
|
|
@ -367,6 +375,7 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
balanceChangeRecord.setChangeAmount(amount);
|
||||
balanceChangeRecord.setResultAmount(NumberUtil.add(amount, aiUser.getBalance()));
|
||||
balanceChangeRecord.setRemark(remark);
|
||||
balanceChangeRecord.setOrderNo(orderNo);
|
||||
balanceChangeRecordService.insertAiBalanceChangeRecord(balanceChangeRecord);
|
||||
// 修改用户余额
|
||||
aiUser.setBalance(balanceChangeRecord.getResultAmount());
|
||||
|
|
@ -396,7 +405,7 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
public int updatePassword(AiUser aiUser) {
|
||||
aiUser.setPassword(SecurityUtils.encryptPassword(aiUser.getNewPassword()));
|
||||
return aiUserMapper.updateById(aiUser);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
|
|
@ -410,14 +419,14 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
// if (deductAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
// deductAmount = new BigDecimal(0);
|
||||
// }
|
||||
addUserBalance(Long.valueOf(userId), NumberUtil.mul(aiSampleAmountRecord.getSampleAmount(), -1), BalanceChangerConstants.EXPERIENCE_GOLD_RECYCLE);
|
||||
addUserBalance(aiSampleAmountRecord.getOrderNo(), Long.valueOf(userId), NumberUtil.mul(aiSampleAmountRecord.getSampleAmount(), -1), BalanceChangerConstants.EXPERIENCE_GOLD_RECYCLE);
|
||||
// 修改状态已回收
|
||||
aiSampleAmountRecord.setStatus(1);
|
||||
aiSampleAmountRecordService.updateAiSampleAmountRecord(aiSampleAmountRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRebate(Long userId, BigDecimal amount) {
|
||||
public void handleRebate(String orderNo, Long userId, BigDecimal amount) {
|
||||
AiUser aiUser = aiUserMapper.selectById(userId);
|
||||
if (aiUser.getSuperiorId() == null) return;
|
||||
// 查询上级用户
|
||||
|
|
@ -428,7 +437,7 @@ public class AiUserServiceImpl implements IAiUserService {
|
|||
SysConfig sysConfig = sysConfigMapper.checkConfigKeyUnique(AiConfigEnum.SYS_INVITE_COMMISSION.getValue());
|
||||
double decimal = NumberUtil.div(Integer.parseInt(sysConfig.getConfigValue()), 100, 2);
|
||||
BigDecimal rebateAmount = NumberUtil.mul(amount, decimal);
|
||||
addUserBalance(superiorUser.getId(), rebateAmount, BalanceChangerConstants.REBATE);
|
||||
addUserBalance(orderNo, superiorUser.getId(), rebateAmount, BalanceChangerConstants.REBATE);
|
||||
// 新增返佣记录
|
||||
AiRebateRecord rebateRecord = new AiRebateRecord();
|
||||
rebateRecord.setSuperiorId(superiorUser.getId());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import com.ruoyi.ai.service.IByteDeptApiKeyService;
|
||||
import com.ruoyi.ai.service.IAiUserService;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import com.ruoyi.common.core.domain.entity.SysDept;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.system.service.ISysDeptService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService {
|
||||
|
||||
private static final String NO_DEPT_MSG = "用户未分配部门:请在后台为门户用户设置 ai_user.dept_id(关联 sys_dept.dept_id)";
|
||||
private static final String NO_DEPT_ROW_MSG = "用户所属部门不存在或已删除,请核对 ai_user.dept_id";
|
||||
private static final String NO_API_KEY_MSG = "部门未配置火山 API Key:请在 sys_dept 为用户所在部门(或其上级二级部门)配置 byte_api_key";
|
||||
private static final int CACHE_HOURS = 1;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Autowired
|
||||
private IAiUserService aiUserService;
|
||||
|
||||
@Autowired
|
||||
private ISysDeptService sysDeptService;
|
||||
|
||||
@Override
|
||||
public String resolveVolcApiKey(Long aiUserId) {
|
||||
if (aiUserId == null) {
|
||||
throw new ServiceException(NO_DEPT_MSG);
|
||||
}
|
||||
String cacheKey = aiUserId + "_byte_api_key";
|
||||
String cached = redisCache.getCacheObject(cacheKey);
|
||||
if (StringUtils.isNotEmpty(cached)) {
|
||||
return cached;
|
||||
}
|
||||
AiUser aiUser = aiUserService.selectAiUserById(aiUserId);
|
||||
if (aiUser == null || aiUser.getDeptId() == null) {
|
||||
throw new ServiceException(NO_DEPT_MSG);
|
||||
}
|
||||
SysDept userDept = sysDeptService.selectDeptById(aiUser.getDeptId());
|
||||
if (userDept == null) {
|
||||
throw new ServiceException(NO_DEPT_ROW_MSG);
|
||||
}
|
||||
// 优先使用用户直接归属部门的 Key;多数业务把用户挂在分公司(如 101)并在该节点配 byte_api_key
|
||||
String apiKey = trimKey(userDept.getByteApiKey());
|
||||
if (StringUtils.isEmpty(apiKey)) {
|
||||
Long fallbackDeptId = resolveSecondLevelDeptId(userDept);
|
||||
if (!fallbackDeptId.equals(userDept.getDeptId())) {
|
||||
SysDept keyDept = sysDeptService.selectDeptById(fallbackDeptId);
|
||||
if (keyDept != null) {
|
||||
apiKey = trimKey(keyDept.getByteApiKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isEmpty(apiKey)) {
|
||||
throw new ServiceException(NO_API_KEY_MSG);
|
||||
}
|
||||
redisCache.setCacheObject(cacheKey, apiKey, CACHE_HOURS, TimeUnit.HOURS);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
private static String trimKey(String raw) {
|
||||
return raw == null ? null : raw.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级部门:祖级路径中,紧接在「一级」(ancestors 第二段)之下的部门节点;
|
||||
* 深度不足时退回用户当前部门。
|
||||
*/
|
||||
private Long resolveSecondLevelDeptId(SysDept userDept) {
|
||||
String ancestors = userDept.getAncestors();
|
||||
if (StringUtils.isEmpty(ancestors)) {
|
||||
return userDept.getDeptId();
|
||||
}
|
||||
String[] parts = ancestors.split(",");
|
||||
if (parts.length >= 3) {
|
||||
try {
|
||||
return Long.parseLong(parts[2].trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return userDept.getDeptId();
|
||||
}
|
||||
}
|
||||
if (parts.length == 2) {
|
||||
try {
|
||||
return Long.parseLong(parts[1].trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return userDept.getDeptId();
|
||||
}
|
||||
}
|
||||
return userDept.getDeptId();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.ruoyi.ai.domain.ByteBodyReq;
|
||||
import com.ruoyi.ai.domain.ByteBodyRes;
|
||||
import com.ruoyi.ai.service.IByteService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -30,6 +32,13 @@ public class ByteService implements IByteService {
|
|||
@Value("${byteapi.apiKey}")
|
||||
private String apiKey;
|
||||
|
||||
// 火山引擎配置
|
||||
@Value("${volcengine.ark.baseUrl:https://ark.cn-beijing.volces.com}")
|
||||
private String volcBaseUrl;
|
||||
|
||||
@Value("${volcengine.ark.apiKey}")
|
||||
private String volcApiKey;
|
||||
|
||||
@Override
|
||||
public ByteBodyRes promptToImg(ByteBodyReq req) throws Exception {
|
||||
return this.imgToImg(req);
|
||||
|
|
@ -75,71 +84,148 @@ public class ByteService implements IByteService {
|
|||
|
||||
@Override
|
||||
public ByteBodyRes imgToVideo(ByteBodyReq req) throws Exception {
|
||||
return imgToVideo(req, volcApiKey);
|
||||
}
|
||||
|
||||
// 1. 验证请求参数(可选,根据业务需求)
|
||||
// if (StringUtils.isBlank(req.getPrompt())) {
|
||||
// throw new Exception("imgToVideo error:prompt is null");
|
||||
// }
|
||||
@Override
|
||||
public ByteBodyRes imgToVideo(ByteBodyReq req, String arkApiKey) throws Exception {
|
||||
if (req == null) {
|
||||
throw new Exception("imgToVideo error:req is null");
|
||||
}
|
||||
if (StringUtils.isBlank(arkApiKey)) {
|
||||
throw new Exception("imgToVideo error:apiKey is null");
|
||||
}
|
||||
|
||||
// 2. 构建请求体JSON(基于ByteBodyReq的字段)
|
||||
// 注意:ByteBodyReq需包含与API参数对应的字段(model、prompt等)
|
||||
String jsonBody = objectMapper.writeValueAsString(req);
|
||||
// 3. 构建请求
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(API_URL + "/contents/generations/tasks")
|
||||
.url(volcBaseUrl + "/api/v3/contents/generations/tasks")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Authorization", "Bearer " + arkApiKey)
|
||||
.post(RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"),
|
||||
jsonBody
|
||||
))
|
||||
.build();
|
||||
// 4. 发送同步请求(因方法需要返回值,使用execute而非enqueue)
|
||||
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
// 5. 处理响应
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
// 非200状态:返回错误信息(假设ByteBodyRes有error字段)
|
||||
String errorMsg = response.body() != null ? response.body().string() : "imgToVideo error";
|
||||
throw new Exception("imgToVideo error:" + errorMsg);
|
||||
}
|
||||
// 6. 解析成功响应为ByteBodyRes
|
||||
|
||||
if (response.body() == null) {
|
||||
throw new Exception("imgToVideo response null");
|
||||
}
|
||||
|
||||
String responseBody = response.body().string();
|
||||
return objectMapper.readValue(responseBody, ByteBodyRes.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBodyRes uploadVideo(String id) throws Exception {
|
||||
// 1. 验证请求参数(可选,根据业务需求)
|
||||
return uploadVideo(id, volcApiKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBodyRes uploadVideo(String id, String arkApiKey) throws Exception {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
throw new Exception("uploadVideo error:id is null");
|
||||
}
|
||||
if (StringUtils.isBlank(arkApiKey)) {
|
||||
throw new Exception("uploadVideo error:apiKey is null");
|
||||
}
|
||||
|
||||
// 2. 构建请求体JSON(基于ByteBodyReq的字段)
|
||||
// 注意:ByteBodyReq需包含与API参数对应的字段(model、prompt等)
|
||||
//String jsonBody = objectMapper.writeValueAsString(req);
|
||||
// 3. 构建请求
|
||||
Request request = new Request.Builder()
|
||||
.url(API_URL + "/contents/generations/tasks/" + id)
|
||||
.url(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Authorization", "Bearer " + arkApiKey)
|
||||
.get()
|
||||
.build();
|
||||
// 4. 发送同步请求(因方法需要返回值,使用execute而非enqueue)
|
||||
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
// 5. 处理响应
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
// 非200状态:返回错误信息(假设ByteBodyRes有error字段)
|
||||
String errorMsg = response.body() != null ? response.body().string() : "uploadVideo error";
|
||||
throw new Exception("uploadVideo error:" + errorMsg);
|
||||
}
|
||||
// 6. 解析成功响应为ByteBodyRes
|
||||
|
||||
if (response.body() == null) {
|
||||
throw new Exception("uploadVideo response null");
|
||||
}
|
||||
|
||||
String responseBody = response.body().string();
|
||||
return objectMapper.readValue(responseBody, ByteBodyRes.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AjaxResult cancelVideoTask(String id) throws Exception {
|
||||
return cancelVideoTask(id, volcApiKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AjaxResult cancelVideoTask(String id, String arkApiKey) throws Exception {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return AjaxResult.error("任务ID不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(arkApiKey)) {
|
||||
return AjaxResult.error("API Key 无效");
|
||||
}
|
||||
|
||||
try {
|
||||
Request request = new Request.Builder()
|
||||
.url(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + arkApiKey)
|
||||
.delete()
|
||||
.build();
|
||||
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
String errorMsg = response.body() != null ? response.body().string() : "cancel failed";
|
||||
return AjaxResult.error("取消任务失败:" + errorMsg);
|
||||
}
|
||||
|
||||
return AjaxResult.success("任务已取消,余额已退回");
|
||||
} catch (Exception e) {
|
||||
return AjaxResult.error("取消任务异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception {
|
||||
if (StringUtils.isBlank(arkApiKey)) {
|
||||
throw new Exception("listVideoGenerationTasks error:apiKey is null");
|
||||
}
|
||||
int pn = pageNum > 0 ? pageNum : 1;
|
||||
int ps = pageSize > 0 ? Math.min(pageSize, 500) : 10;
|
||||
|
||||
HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks");
|
||||
if (parsed == null) {
|
||||
throw new Exception("listVideoGenerationTasks error:invalid base url");
|
||||
}
|
||||
HttpUrl url = parsed.newBuilder()
|
||||
.addQueryParameter("page_num", String.valueOf(pn))
|
||||
.addQueryParameter("page_size", String.valueOf(ps))
|
||||
.build();
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + arkApiKey)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
if (response.body() == null) {
|
||||
throw new Exception("listVideoGenerationTasks response null");
|
||||
}
|
||||
String body = response.body().string();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Exception("listVideoGenerationTasks error:" + body);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ruoyi.ai.service.IExchangeRateService;
|
||||
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 汇率服务实现类
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ExchangeRateServiceImpl implements IExchangeRateService {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${exchange-rate.api-url:https://api.currencyfreaks.com/v2.0/rates/latest}")
|
||||
private String apiUrl;
|
||||
|
||||
@Value("${exchange-rate.apikey:}")
|
||||
private String apikey;
|
||||
|
||||
@Value("${exchange-rate.base-currency:USD}")
|
||||
private String baseCurrency;
|
||||
|
||||
@Value("${exchange-rate.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${exchange-rate.fallback-rate-jinsha:90}")
|
||||
private BigDecimal fallbackRateJinSha;
|
||||
|
||||
@Value("${exchange-rate.fallback-rate-kada:60}")
|
||||
private BigDecimal fallbackRateKaDa;
|
||||
|
||||
@Value("${exchange-rate.fallback-rate-php:90}")
|
||||
private BigDecimal fallbackRatePHP;
|
||||
|
||||
@Override
|
||||
public BigDecimal getExchangeRate(String fromCurrency, String toCurrency) throws Exception {
|
||||
if (!enabled) {
|
||||
log.warn("汇率服务未启用,使用默认汇率");
|
||||
return getFallbackRate(toCurrency);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建请求URL - 使用 CurrencyFreaks Rates Latest Endpoint
|
||||
String url = buildApiUrl();
|
||||
|
||||
// 构建请求
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
ResponseBody body = response.body();
|
||||
|
||||
if (response.isSuccessful() && body != null) {
|
||||
String responseBody = body.string();
|
||||
log.debug("汇率API响应: {}", responseBody);
|
||||
|
||||
// 解析响应 - CurrencyFreaks Rates Latest Endpoint 响应格式
|
||||
RatesResponse ratesResponse = objectMapper.readValue(responseBody, RatesResponse.class);
|
||||
|
||||
if (ratesResponse != null && ratesResponse.getRates() != null) {
|
||||
BigDecimal rate = calculateRate(ratesResponse, fromCurrency, toCurrency);
|
||||
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
|
||||
log.info("成功获取汇率 {} -> {}: {}", fromCurrency, toCurrency, rate);
|
||||
return rate;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("汇率API请求失败,状态码: {}", response.code());
|
||||
if (body != null) {
|
||||
log.warn("响应内容: {}", body.string());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("获取汇率失败,使用备用汇率: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 如果API调用失败,使用备用汇率
|
||||
return getFallbackRate(toCurrency);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal convertAmount(BigDecimal amount, String fromCurrency, String toCurrency) throws Exception {
|
||||
if (amount == null) {
|
||||
throw new IllegalArgumentException("金额不能为空");
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
log.warn("汇率服务未启用,使用默认汇率计算");
|
||||
BigDecimal rate = getFallbackRate(toCurrency);
|
||||
// 返回完整精度,不限制小数位
|
||||
return amount.multiply(rate);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建请求URL - 使用 CurrencyFreaks Rates Latest Endpoint
|
||||
String url = buildApiUrl();
|
||||
|
||||
// 构建请求
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
ResponseBody body = response.body();
|
||||
|
||||
if (response.isSuccessful() && body != null) {
|
||||
String responseBody = body.string();
|
||||
// log.debug("汇率API响应: {}", responseBody);
|
||||
|
||||
// 解析响应 - CurrencyFreaks Rates Latest Endpoint 响应格式
|
||||
RatesResponse ratesResponse = objectMapper.readValue(responseBody, RatesResponse.class);
|
||||
|
||||
if (ratesResponse != null && ratesResponse.getRates() != null) {
|
||||
BigDecimal rate = calculateRate(ratesResponse, fromCurrency, toCurrency);
|
||||
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
|
||||
BigDecimal convertedAmount = amount.multiply(rate);
|
||||
log.info("成功转换金额 {} {} -> {} {}: {}",
|
||||
amount, fromCurrency, convertedAmount, toCurrency, rate);
|
||||
// 返回完整精度,不限制小数位
|
||||
return convertedAmount;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("汇率API请求失败,状态码: {}", response.code());
|
||||
if (body != null) {
|
||||
log.warn("响应内容: {}", body.string());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("转换金额失败,使用备用汇率计算: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 如果API调用失败,使用备用汇率计算(不限制小数位)
|
||||
BigDecimal rate = getFallbackRate(toCurrency);
|
||||
return amount.multiply(rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建API URL - 使用 CurrencyFreaks Rates Latest Endpoint
|
||||
*/
|
||||
private String buildApiUrl() {
|
||||
if (apiUrl.contains("currencyfreaks.com") && apiUrl.contains("/rates/latest")) {
|
||||
// 使用 CurrencyFreaks Rates Latest Endpoint 格式
|
||||
// https://api.currencyfreaks.com/v2.0/rates/latest?apikey=YOUR_APIKEY&base=USD
|
||||
StringBuilder urlBuilder = new StringBuilder(apiUrl);
|
||||
urlBuilder.append("?apikey=").append(apikey);
|
||||
urlBuilder.append("&base=").append(baseCurrency.toUpperCase());
|
||||
return urlBuilder.toString();
|
||||
} else {
|
||||
// 兼容其他API格式(向后兼容)
|
||||
return apiUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据汇率响应计算 fromCurrency 到 toCurrency 的汇率
|
||||
*/
|
||||
private BigDecimal calculateRate(RatesResponse ratesResponse, String fromCurrency, String toCurrency) {
|
||||
String base = ratesResponse.getBase();
|
||||
java.util.Map<String, String> rates = ratesResponse.getRates();
|
||||
|
||||
if (rates == null || rates.isEmpty()) {
|
||||
log.warn("汇率响应中 rates 为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
String fromUpper = fromCurrency.toUpperCase();
|
||||
String toUpper = toCurrency.toUpperCase();
|
||||
String baseUpper = base.toUpperCase();
|
||||
|
||||
// 如果源货币就是基准货币
|
||||
if (baseUpper.equals(fromUpper)) {
|
||||
String toRateStr = rates.get(toUpper);
|
||||
if (toRateStr != null) {
|
||||
try {
|
||||
return new BigDecimal(toRateStr);
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("无法解析目标货币 {} 的汇率: {}", toUpper, toRateStr);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.warn("未找到目标货币 {} 的汇率", toUpper);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果目标货币就是基准货币
|
||||
if (baseUpper.equals(toUpper)) {
|
||||
String fromRateStr = rates.get(fromUpper);
|
||||
if (fromRateStr != null) {
|
||||
try {
|
||||
// 如果目标货币是基准货币,汇率是 1 / fromRate
|
||||
BigDecimal fromRate = new BigDecimal(fromRateStr);
|
||||
if (fromRate.compareTo(BigDecimal.ZERO) > 0) {
|
||||
return BigDecimal.ONE.divide(fromRate, 10, RoundingMode.HALF_UP);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("无法解析源货币 {} 的汇率: {}", fromUpper, fromRateStr);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.warn("未找到源货币 {} 的汇率", fromUpper);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 两个货币都不是基准货币,需要计算
|
||||
String fromRateStr = rates.get(fromUpper);
|
||||
String toRateStr = rates.get(toUpper);
|
||||
|
||||
if (fromRateStr == null) {
|
||||
log.warn("未找到源货币 {} 的汇率", fromUpper);
|
||||
return null;
|
||||
}
|
||||
if (toRateStr == null) {
|
||||
log.warn("未找到目标货币 {} 的汇率", toUpper);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
BigDecimal fromRate = new BigDecimal(fromRateStr);
|
||||
BigDecimal toRate = new BigDecimal(toRateStr);
|
||||
|
||||
if (fromRate.compareTo(BigDecimal.ZERO) > 0) {
|
||||
// 汇率 = toRate / fromRate
|
||||
// 例如:base=USD, from=CNY, to=PHP
|
||||
// USD->CNY = fromRate, USD->PHP = toRate
|
||||
// CNY->PHP = toRate / fromRate
|
||||
return toRate.divide(fromRate, 10, RoundingMode.HALF_UP);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("无法解析汇率: from={}, to={}", fromRateStr, toRateStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备用汇率(当API调用失败时使用)
|
||||
*/
|
||||
private BigDecimal getFallbackRate(String toCurrency) {
|
||||
// 根据目标货币返回不同的备用汇率
|
||||
if ("PHP".equalsIgnoreCase(toCurrency)) {
|
||||
// PHP比索,使用配置的PHP备用汇率
|
||||
log.warn("使用PHP备用汇率: {}", fallbackRatePHP);
|
||||
return fallbackRatePHP;
|
||||
}
|
||||
// 其他货币可以扩展,默认使用PHP的备用汇率
|
||||
log.warn("未配置货币 {} 的备用汇率,使用PHP默认汇率 {}", toCurrency, fallbackRatePHP);
|
||||
return fallbackRatePHP;
|
||||
}
|
||||
|
||||
/**
|
||||
* CurrencyFreaks Rates Latest Endpoint 响应对象
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
static class RatesResponse {
|
||||
@JsonProperty("date")
|
||||
private String date;
|
||||
|
||||
@JsonProperty("base")
|
||||
private String base;
|
||||
|
||||
@JsonProperty("rates")
|
||||
private java.util.Map<String, String> rates;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,10 @@ public class JinShaService implements IJinShaService {
|
|||
private IAiUserService aiUserService;
|
||||
@Autowired
|
||||
private IAiRechargeGiftGearService aiRechargeGiftGearService;
|
||||
@Autowired
|
||||
private IAiRechargeGiftService aiRechargeGiftService;
|
||||
@Autowired
|
||||
private IExchangeRateService exchangeRateService;
|
||||
|
||||
@Override
|
||||
public PayResVO jinShaPay(Long gearId) throws Exception {
|
||||
|
|
@ -57,64 +61,104 @@ public class JinShaService implements IJinShaService {
|
|||
throw new ServiceException("The gear position does not exist.", -1);
|
||||
}
|
||||
BigDecimal amount = aiRechargeGiftGear.getRechargeAmount();
|
||||
amount = amount.multiply(new BigDecimal(90));
|
||||
// 使用汇率服务转换金额(从CNY转换为目标货币,默认INR)
|
||||
amount = exchangeRateService.convertAmount(amount, "USD", "INR");
|
||||
|
||||
JinShaBodyReq req = new JinShaBodyReq();
|
||||
req.setAppid(appId);
|
||||
req.setReturn_url(returnUrl + "/recharge");
|
||||
req.setNotify_url(notifyUrl + "/api/pay/jinsha-callBack");
|
||||
req.setAmount(amount);
|
||||
// 1. 配置请求参数
|
||||
TreeMap<String, Object> params = new TreeMap<>();
|
||||
params.put("appid", req.getAppid());
|
||||
// 生成订单号
|
||||
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
String orderNo = dateTime + "js" + uuid;
|
||||
params.put("orderno", orderNo);
|
||||
params.put("amount", amount);
|
||||
params.put("notify_url", req.getNotify_url());
|
||||
params.put("return_url", req.getReturn_url());
|
||||
StringBuilder append = this.append(params);
|
||||
String sign = this.sign(append);
|
||||
String formData = append + "&sign=" + sign;
|
||||
// 2. 构建请求体(form-urlencoded 格式)
|
||||
RequestBody requestBody = RequestBody.create(
|
||||
MediaType.get("application/x-www-form-urlencoded;charset=utf-8"),
|
||||
formData
|
||||
);
|
||||
// 3. 构建 POST 请求
|
||||
|
||||
// 金额转换为number类型(保留两位小数)
|
||||
String amountStr = String.format("%.2f", amount.doubleValue());
|
||||
|
||||
// 构建回调地址和返回地址
|
||||
String notifyUrlFull = notifyUrl + "/api/pay/jinsha-callBack";
|
||||
String returnUrlFull = returnUrl + "/recharge";
|
||||
|
||||
// 1. 配置签名参数(按照key排序:appid, amount, orderno, notify_url, return_url,不包含sign)
|
||||
TreeMap<String, Object> signParams = new TreeMap<>();
|
||||
signParams.put("appid", appId);
|
||||
signParams.put("amount", amountStr);
|
||||
signParams.put("orderno", orderNo);
|
||||
signParams.put("notify_url", notifyUrlFull);
|
||||
signParams.put("return_url", returnUrlFull);
|
||||
|
||||
// 2. 生成签名
|
||||
String sign = this.generateSign(signParams);
|
||||
|
||||
// 3. 构建请求参数(使用FormBody.Builder自动处理URL编码)
|
||||
FormBody.Builder formBuilder = new FormBody.Builder();
|
||||
formBuilder.add("appid", appId);
|
||||
formBuilder.add("amount", amountStr);
|
||||
formBuilder.add("orderno", orderNo);
|
||||
formBuilder.add("notify_url", notifyUrlFull);
|
||||
formBuilder.add("return_url", returnUrlFull);
|
||||
formBuilder.add("sign", sign);
|
||||
RequestBody requestBody = formBuilder.build();
|
||||
|
||||
// 4. 构建 POST 请求
|
||||
Request request = new Request.Builder()
|
||||
.url(url + "/api/pay")
|
||||
// .header("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||
.post(requestBody)
|
||||
.build();
|
||||
// 4. 发送请求并获取响应
|
||||
|
||||
// 5. 发送请求并获取响应
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
ResponseBody body = response.body();
|
||||
if (null == body) {
|
||||
log.error("kada支付请求的响应异常");
|
||||
throw new Exception("kadapay responsebody is null");
|
||||
log.error("jinsha支付请求的响应异常");
|
||||
throw new Exception("jinshapay responsebody is null");
|
||||
}
|
||||
JinShaBodyRes jinShaBodyRes = objectMapper.readValue(body.string(), JinShaBodyRes.class);
|
||||
String responseBody = body.string();
|
||||
log.info("jinsha支付请求响应: {}", responseBody);
|
||||
JinShaBodyRes jinShaBodyRes = objectMapper.readValue(responseBody, JinShaBodyRes.class);
|
||||
|
||||
// 检查响应code,0表示成功,其他值表示失败
|
||||
Integer code = jinShaBodyRes.getCode();
|
||||
if (code == null || code != 0) {
|
||||
String msg = jinShaBodyRes.getMsg();
|
||||
log.error("jinsha支付请求失败,code: {}, msg: {}", code, msg);
|
||||
throw new ServiceException(msg != null ? msg : "支付请求失败", code != null ? code : -1);
|
||||
}
|
||||
|
||||
// 检查返回的payurl
|
||||
String payUrl = null;
|
||||
if (jinShaBodyRes.getData() != null) {
|
||||
payUrl = jinShaBodyRes.getData().getPayurl();
|
||||
}
|
||||
if (payUrl == null || payUrl.trim().isEmpty()) {
|
||||
log.error("jinsha支付返回的payurl为空,订单号: {}", orderNo);
|
||||
throw new ServiceException("支付返回的支付链接为空", -1);
|
||||
}
|
||||
|
||||
// 创建充值管理
|
||||
AiUser userInfo = aiUserService.getUserInfo(SecurityUtils.getAiUserId());
|
||||
AiRecharge aiRecharge = new AiRecharge();
|
||||
aiRecharge.setOrderNum(orderNo);
|
||||
aiRecharge.setUserId(SecurityUtils.getAiUserId());
|
||||
aiRecharge.setAmount(amount);
|
||||
aiRecharge.setGearId(req.getGearId());
|
||||
aiRecharge.setGearId(gearId);
|
||||
aiRecharge.setGearAmount(aiRechargeGiftGear.getRechargeAmount());
|
||||
aiRecharge.setSource(userInfo.getSource());
|
||||
AiRechargeGift aiRechargeGift = aiRechargeGiftService.selectAiRechargeGiftById(aiRechargeGiftGear.getRechargeId());
|
||||
aiRecharge.setPayType(aiRechargeGift.getPayType());
|
||||
aiRecharge.setPayUrl(payUrl);
|
||||
aiRechargeService.insertAiRecharge(aiRecharge);
|
||||
|
||||
PayResVO payResVO = new PayResVO();
|
||||
payResVO.setOrderNo(orderNo);
|
||||
payResVO.setPayUrl(jinShaBodyRes.getData().getPayurl());
|
||||
payResVO.setPayUrl(payUrl);
|
||||
return payResVO;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public AjaxResult jinShaCall(JinShaBodyCall req) {
|
||||
log.info("jinsha支付回调,订单号: {}, 状态: {}", req.getOrderno(), req.getStatus());
|
||||
|
||||
// 验证签名
|
||||
TreeMap<String, Object> params = new TreeMap<>();
|
||||
params.put("status", req.getStatus());
|
||||
params.put("orderno", req.getOrderno());
|
||||
|
|
@ -123,14 +167,58 @@ public class JinShaService implements IJinShaService {
|
|||
StringBuilder append = this.append(params);
|
||||
String sign = this.sign(append);
|
||||
if (!sign.equals(req.getSign())) {
|
||||
log.error("支付回调签名错误 {}", req.getOrderno());
|
||||
return AjaxResult.error();
|
||||
log.error("jinsha支付回调签名错误,订单号: {}, 期望签名: {}, 实际签名: {}",
|
||||
req.getOrderno(), sign, req.getSign());
|
||||
return AjaxResult.error("签名验证失败");
|
||||
}
|
||||
|
||||
// 处理订单状态
|
||||
// status:0=>处理中, status:1=>成功, status:2=>失败
|
||||
Integer status = req.getStatus();
|
||||
if (status == null) {
|
||||
log.error("jinsha支付回调状态为空,订单号: {}", req.getOrderno());
|
||||
return AjaxResult.error("状态为空");
|
||||
}
|
||||
|
||||
if (status == 1) {
|
||||
// 充值成功处理
|
||||
log.info("jinsha支付成功,订单号: {}", req.getOrderno());
|
||||
aiRechargeService.addRecharge(req.getOrderno());
|
||||
return AjaxResult.success();
|
||||
} else if (status == 2) {
|
||||
// 支付失败
|
||||
log.warn("jinsha支付失败,订单号: {}", req.getOrderno());
|
||||
// 这里可以添加失败处理逻辑,比如更新订单状态为失败
|
||||
return AjaxResult.success();
|
||||
} else {
|
||||
// 处理中(status:0)
|
||||
log.info("jinsha支付处理中,订单号: {}", req.getOrderno());
|
||||
return AjaxResult.success();
|
||||
}
|
||||
//充值成功处理
|
||||
aiRechargeService.addRecharge(req.getOrderno());
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
* 按照文档要求:key=value&key=value&...&secret=secret,然后MD5
|
||||
*/
|
||||
private String generateSign(TreeMap<String, Object> sortedParams) {
|
||||
StringBuilder paramSb = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : sortedParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
// 拼接格式:key=value&
|
||||
paramSb.append(key).append("=").append(value).append("&");
|
||||
}
|
||||
// 最后添加secret
|
||||
paramSb.append("secret=").append(secret);
|
||||
String signSource = paramSb.toString();
|
||||
log.debug("jinsha签名源字符串: {}", signSource);
|
||||
return Md5Utils.hash(signSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧的签名方法(回调使用)
|
||||
*/
|
||||
private StringBuilder append(TreeMap<String, Object> sortedParams) {
|
||||
StringBuilder paramSb = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : sortedParams.entrySet()) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ public class KaDaService implements IKaDaService {
|
|||
private IAiRechargeService aiRechargeService;
|
||||
@Autowired
|
||||
private IAiRechargeGiftService aiRechargeGiftService;
|
||||
@Autowired
|
||||
private IExchangeRateService exchangeRateService;
|
||||
|
||||
|
||||
@Override
|
||||
|
|
@ -65,7 +67,8 @@ public class KaDaService implements IKaDaService {
|
|||
throw new ServiceException("The gear position does not exist.", -1);
|
||||
}
|
||||
BigDecimal amount = aiRechargeGiftGear.getRechargeAmount();
|
||||
amount = amount.multiply(new BigDecimal(60.00));
|
||||
// 使用汇率服务转换金额(从CNY转换为目标货币,默认PHP)
|
||||
amount = exchangeRateService.convertAmount(amount, "USD", "PHP");
|
||||
|
||||
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,470 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ai.domain.*;
|
||||
import com.ruoyi.ai.service.*;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||
import com.ruoyi.common.utils.sign.Md5Utils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* VM支付服务实现
|
||||
*
|
||||
* @author system
|
||||
* @date 2025-01-XX
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class VmService implements IVmService {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
|
||||
@Value("${vm.url:http://payment-api.togame.top}")
|
||||
private String url;
|
||||
|
||||
@Value("${vm.mchNo}")
|
||||
private String mchNo;
|
||||
|
||||
@Value("${vm.appId}")
|
||||
private String appId;
|
||||
|
||||
@Value("${vm.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${vm.notifyUrl}")
|
||||
private String notifyUrl;
|
||||
|
||||
@Value("${vm.wayCode:BUZHI_VM}")
|
||||
private String wayCode;
|
||||
|
||||
@Value("${vm.currency:USD}")
|
||||
private String currency;
|
||||
|
||||
@Autowired
|
||||
private IAiRechargeService aiRechargeService;
|
||||
|
||||
@Autowired
|
||||
private IAiUserService aiUserService;
|
||||
|
||||
@Autowired
|
||||
private IAiRechargeGiftGearService aiRechargeGiftGearService;
|
||||
|
||||
@Autowired
|
||||
private IAiRechargeGiftService aiRechargeGiftService;
|
||||
|
||||
@Autowired
|
||||
private IExchangeRateService exchangeRateService;
|
||||
|
||||
@Override
|
||||
public PayResVO vmPay(Long gearId, VmCardInfo vmCardInfo, String clientIp) throws Exception {
|
||||
// 充值挡位查询
|
||||
AiRechargeGiftGear aiRechargeGiftGear = aiRechargeGiftGearService.selectAiRechargeGiftGearById(gearId);
|
||||
if (aiRechargeGiftGear == null) {
|
||||
throw new ServiceException("The gear position does not exist.", -1);
|
||||
}
|
||||
|
||||
BigDecimal amount = aiRechargeGiftGear.getRechargeAmount();
|
||||
// 使用汇率服务转换金额(从USD转换为目标货币,默认USD)
|
||||
// 注意:VM支付使用USD作为基础货币
|
||||
if (!"USD".equalsIgnoreCase(currency)) {
|
||||
amount = exchangeRateService.convertAmount(amount, "USD", currency);
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
String orderNo = dateTime + "vm" + uuid;
|
||||
|
||||
// 金额转换为分(整数)
|
||||
int amountInCents = amount.multiply(new BigDecimal(100)).intValue();
|
||||
|
||||
// 构建extParam(VM卡信息)
|
||||
String extParam = null;
|
||||
if (vmCardInfo != null) {
|
||||
extParam = objectMapper.writeValueAsString(vmCardInfo);
|
||||
}
|
||||
|
||||
// 构建统一下单请求
|
||||
VmUnifiedOrderReq req = new VmUnifiedOrderReq();
|
||||
req.setMchNo(mchNo);
|
||||
req.setAppId(appId);
|
||||
req.setMchOrderNo(orderNo);
|
||||
req.setWayCode(wayCode);
|
||||
req.setAmount(amountInCents);
|
||||
req.setCurrency(currency.toLowerCase());
|
||||
req.setSubject("游戏充值");
|
||||
req.setBody("游戏充值");
|
||||
req.setNotifyUrl(notifyUrl + "/api/pay/vm-callBack");
|
||||
req.setExpiredTime(7200); // 默认2小时
|
||||
req.setExtParam(extParam);
|
||||
req.setReqTime(System.currentTimeMillis());
|
||||
req.setVersion("1.0");
|
||||
req.setSignType("MD5");
|
||||
// 设置客户端IP
|
||||
if (clientIp != null && !clientIp.trim().isEmpty()) {
|
||||
req.setClientIp(clientIp);
|
||||
log.debug("VM支付设置客户端IP: {}", clientIp);
|
||||
} else {
|
||||
log.warn("VM支付客户端IP为空,订单号: {}", orderNo);
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
String sign = generateSign(req);
|
||||
req.setSign(sign);
|
||||
|
||||
// 构建请求体JSON
|
||||
String jsonBody = objectMapper.writeValueAsString(req);
|
||||
log.info("VM支付请求参数: {}", jsonBody);
|
||||
|
||||
// 构建POST请求
|
||||
Request request = new Request.Builder()
|
||||
.url(url + "/api/pay/unifiedOrder")
|
||||
.header("Content-Type", "application/json")
|
||||
.post(RequestBody.create(MediaType.parse("application/json"), jsonBody))
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
Response response = OkHttpUtils.newCall(request).execute();
|
||||
ResponseBody body = response.body();
|
||||
|
||||
if (body == null) {
|
||||
log.error("VM支付请求的响应异常");
|
||||
throw new Exception("vm pay response body is null");
|
||||
}
|
||||
|
||||
String responseBody = body.string();
|
||||
log.info("VM支付请求响应: {}", responseBody);
|
||||
|
||||
VmUnifiedOrderRes res = objectMapper.readValue(responseBody, VmUnifiedOrderRes.class);
|
||||
|
||||
// 检查响应code,0表示成功
|
||||
if (res.getCode() == null || res.getCode() != 0) {
|
||||
String msg = res.getMsg();
|
||||
log.error("VM支付请求失败,code: {}, msg: {}", res.getCode(), msg);
|
||||
throw new ServiceException(msg != null ? msg : "支付请求失败", res.getCode() != null ? res.getCode() : -1);
|
||||
}
|
||||
|
||||
// 检查返回数据
|
||||
if (res.getData() == null) {
|
||||
log.error("VM支付返回的data为空,订单号: {}", orderNo);
|
||||
throw new ServiceException("支付返回的数据为空", -1);
|
||||
}
|
||||
|
||||
VmUnifiedOrderRes.VmUnifiedOrderData data = res.getData();
|
||||
|
||||
// 记录关键字段信息
|
||||
String payOrderId = data.getPayOrderId();
|
||||
String mchOrderNo = data.getMchOrderNo();
|
||||
log.info("VM支付返回数据,商户订单号: {}, 支付订单号: {}, 订单状态: {}, payDataType: {}",
|
||||
mchOrderNo, payOrderId, data.getOrderState(), data.getPayDataType());
|
||||
|
||||
// 检查订单状态
|
||||
Integer orderState = data.getOrderState();
|
||||
if (orderState == null) {
|
||||
log.error("VM支付返回的订单状态为空,订单号: {}", orderNo);
|
||||
throw new ServiceException("支付返回的订单状态为空", -1);
|
||||
}
|
||||
|
||||
// 订单状态说明:
|
||||
// 0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
|
||||
String payUrl = null;
|
||||
String payDataType = data.getPayDataType();
|
||||
String payData = data.getPayData();
|
||||
|
||||
// 如果订单状态是支付失败(3),抛出异常
|
||||
if (orderState == 3) {
|
||||
String errCode = data.getErrCode();
|
||||
String errMsg = data.getErrMsg();
|
||||
String errorMessage = "支付失败";
|
||||
if (errMsg != null && !errMsg.trim().isEmpty()) {
|
||||
errorMessage = errMsg;
|
||||
} else if (errCode != null && !errCode.trim().isEmpty()) {
|
||||
errorMessage = String.format("支付失败,错误码: %s", errCode);
|
||||
}
|
||||
log.error("VM支付失败,订单号: {}, 订单状态: {}, 错误码: {}, 错误消息: {}",
|
||||
orderNo, orderState, errCode, errMsg);
|
||||
throw new ServiceException(errorMessage, -1);
|
||||
}
|
||||
|
||||
// 如果订单状态是支付中(1),需要获取支付URL让用户去支付
|
||||
// 如果订单状态是支付成功(2),不需要payUrl(用户说"订单状态只要是支付成功就不用管订单状态")
|
||||
if (orderState == 1) {
|
||||
// 根据payDataType获取支付URL
|
||||
if (payDataType != null && payData != null && !payData.trim().isEmpty()) {
|
||||
// payurl类型(注意:实际返回是小写payurl):跳转链接的方式
|
||||
if ("payurl".equalsIgnoreCase(payDataType) || "payUrl".equalsIgnoreCase(payDataType)) {
|
||||
payUrl = payData;
|
||||
log.info("VM支付使用payData作为支付URL(payDataType={}),订单号: {}", payDataType, orderNo);
|
||||
}
|
||||
// codeUrl类型:二维码地址
|
||||
else if ("codeUrl".equalsIgnoreCase(payDataType)) {
|
||||
payUrl = payData;
|
||||
log.info("VM支付使用payData作为支付URL(payDataType=codeUrl),订单号: {}", orderNo);
|
||||
}
|
||||
// codeImgUrl类型:二维码图片地址
|
||||
else if ("codeImgUrl".equalsIgnoreCase(payDataType)) {
|
||||
payUrl = payData;
|
||||
log.info("VM支付使用payData作为支付URL(payDataType=codeImgUrl),订单号: {}", orderNo);
|
||||
}
|
||||
// 其他类型(form, wxapp, aliapp, ysfapp, none)暂不处理payUrl
|
||||
else if (!"none".equalsIgnoreCase(payDataType)) {
|
||||
log.info("VM支付payDataType为: {},暂不支持作为支付URL,订单号: {}", payDataType, orderNo);
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版本:如果payUrl字段有值,优先使用
|
||||
if ((payUrl == null || payUrl.trim().isEmpty()) &&
|
||||
data.getPayUrl() != null && !data.getPayUrl().trim().isEmpty()) {
|
||||
payUrl = data.getPayUrl();
|
||||
log.info("VM支付使用payUrl字段,订单号: {}", orderNo);
|
||||
}
|
||||
|
||||
// 如果仍然没有payUrl,记录警告
|
||||
if (payUrl == null || payUrl.trim().isEmpty()) {
|
||||
log.warn("VM支付返回的payUrl为空,订单状态为支付中,订单号: {}, payDataType: {}",
|
||||
orderNo, payDataType);
|
||||
payUrl = "";
|
||||
}
|
||||
} else if (orderState == 2) {
|
||||
// 支付成功,不需要payUrl
|
||||
log.info("VM支付订单状态为支付成功,不需要返回payUrl,订单号: {}", orderNo);
|
||||
payUrl = "";
|
||||
} else {
|
||||
// 其他状态(0-订单生成, 4-已撤销, 5-已退款, 6-订单关闭)
|
||||
log.info("VM支付订单状态: {},订单号: {}", orderState, orderNo);
|
||||
payUrl = "";
|
||||
}
|
||||
|
||||
// 创建充值管理
|
||||
AiUser userInfo = aiUserService.getUserInfo(SecurityUtils.getAiUserId());
|
||||
AiRecharge aiRecharge = new AiRecharge();
|
||||
aiRecharge.setOrderNum(orderNo);
|
||||
aiRecharge.setUserId(SecurityUtils.getAiUserId());
|
||||
aiRecharge.setAmount(amount);
|
||||
aiRecharge.setGearId(gearId);
|
||||
aiRecharge.setGearAmount(aiRechargeGiftGear.getRechargeAmount());
|
||||
aiRecharge.setSource(userInfo.getSource());
|
||||
AiRechargeGift aiRechargeGift = aiRechargeGiftService.selectAiRechargeGiftById(aiRechargeGiftGear.getRechargeId());
|
||||
aiRecharge.setPayType(aiRechargeGift.getPayType());
|
||||
aiRecharge.setPayUrl(payUrl);
|
||||
aiRechargeService.insertAiRecharge(aiRecharge);
|
||||
|
||||
PayResVO payResVO = new PayResVO();
|
||||
payResVO.setOrderNo(orderNo);
|
||||
payResVO.setPayUrl(payUrl);
|
||||
return payResVO;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String vmCallBack(VmCallBackReq req) {
|
||||
log.info("VM支付回调,订单号: {}, 状态: {}", req.getMchOrderNo(), req.getState());
|
||||
|
||||
log.debug("VM支付回调请求对象: mchNo={}, appId={}, mchOrderNo={}, payOrderId={}, ifCode={}, wayCode={}, amount={}, currency={}, state={}, subject={}, body={}, createdAt={}, reqTime={}, sign={}",
|
||||
req.getMchNo(), req.getAppId(), req.getMchOrderNo(), req.getPayOrderId(), req.getIfCode(), req.getWayCode(),
|
||||
req.getAmount(), req.getCurrency(), req.getState(), req.getSubject(), req.getBody(),
|
||||
req.getCreatedAt(), req.getReqTime(), req.getSign());
|
||||
|
||||
// 验证签名
|
||||
String sign = generateCallBackSign(req);
|
||||
if (!sign.equals(req.getSign())) {
|
||||
log.error("VM支付回调签名错误,订单号: {}, 期望签名: {}, 实际签名: {}",
|
||||
req.getMchOrderNo(), sign, req.getSign());
|
||||
return "fail";
|
||||
}
|
||||
|
||||
// 处理订单状态(文档字段名:state)
|
||||
// 0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
|
||||
Integer state = req.getState();
|
||||
if (state == null) {
|
||||
log.warn("VM支付回调state为空,订单号: {},返回 success 等待后续回调", req.getMchOrderNo());
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (state == 2) {
|
||||
log.info("VM支付成功,订单号: {}", req.getMchOrderNo());
|
||||
aiRechargeService.addRecharge(req.getMchOrderNo());
|
||||
return "success";
|
||||
} else if (state == 3) {
|
||||
log.warn("VM支付失败,订单号: {}", req.getMchOrderNo());
|
||||
return "success";
|
||||
} else {
|
||||
log.info("VM支付状态: {}, 订单号: {}", state, req.getMchOrderNo());
|
||||
return "success";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名(统一下单)
|
||||
* 第一步:将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用URL键值对的格式拼接成字符串stringA
|
||||
* 第二步:在stringA最后拼接上key[即 StringA +"&key=" + 私钥 ] 得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写
|
||||
*/
|
||||
private String generateSign(VmUnifiedOrderReq req) {
|
||||
TreeMap<String, Object> params = new TreeMap<>();
|
||||
|
||||
// 添加所有非空参数(sign不参与签名)
|
||||
if (req.getMchNo() != null && !req.getMchNo().isEmpty()) {
|
||||
params.put("mchNo", req.getMchNo());
|
||||
}
|
||||
if (req.getAppId() != null && !req.getAppId().isEmpty()) {
|
||||
params.put("appId", req.getAppId());
|
||||
}
|
||||
if (req.getMchOrderNo() != null && !req.getMchOrderNo().isEmpty()) {
|
||||
params.put("mchOrderNo", req.getMchOrderNo());
|
||||
}
|
||||
if (req.getWayCode() != null && !req.getWayCode().isEmpty()) {
|
||||
params.put("wayCode", req.getWayCode());
|
||||
}
|
||||
if (req.getAmount() != null) {
|
||||
params.put("amount", req.getAmount());
|
||||
}
|
||||
if (req.getCurrency() != null && !req.getCurrency().isEmpty()) {
|
||||
params.put("currency", req.getCurrency());
|
||||
}
|
||||
if (req.getClientIp() != null && !req.getClientIp().isEmpty()) {
|
||||
params.put("clientIp", req.getClientIp());
|
||||
}
|
||||
if (req.getSubject() != null && !req.getSubject().isEmpty()) {
|
||||
params.put("subject", req.getSubject());
|
||||
}
|
||||
if (req.getBody() != null && !req.getBody().isEmpty()) {
|
||||
params.put("body", req.getBody());
|
||||
}
|
||||
if (req.getNotifyUrl() != null && !req.getNotifyUrl().isEmpty()) {
|
||||
params.put("notifyUrl", req.getNotifyUrl());
|
||||
}
|
||||
if (req.getExpiredTime() != null) {
|
||||
params.put("expiredTime", req.getExpiredTime());
|
||||
}
|
||||
if (req.getChannelExtra() != null && !req.getChannelExtra().isEmpty()) {
|
||||
params.put("channelExtra", req.getChannelExtra());
|
||||
}
|
||||
if (req.getExtParam() != null && !req.getExtParam().isEmpty()) {
|
||||
params.put("extParam", req.getExtParam());
|
||||
}
|
||||
if (req.getReqTime() != null) {
|
||||
params.put("reqTime", req.getReqTime());
|
||||
}
|
||||
if (req.getVersion() != null && !req.getVersion().isEmpty()) {
|
||||
params.put("version", req.getVersion());
|
||||
}
|
||||
if (req.getSignType() != null && !req.getSignType().isEmpty()) {
|
||||
params.put("signType", req.getSignType());
|
||||
}
|
||||
|
||||
// 拼接参数
|
||||
StringBuilder stringA = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||
if (stringA.length() > 0) {
|
||||
stringA.append("&");
|
||||
}
|
||||
stringA.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
|
||||
// 拼接key
|
||||
String stringSignTemp = stringA.toString() + "&key=" + secret;
|
||||
log.debug("VM签名源字符串: {}", stringSignTemp);
|
||||
|
||||
// MD5并转大写
|
||||
String sign = Md5Utils.hash(stringSignTemp).toUpperCase();
|
||||
return sign;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成回调签名。按文档字段名:state、reqTime;sign、signType 不参与签名。
|
||||
* 参与签名的非空参数按 key 字典序排序,key=value 用 & 拼接,最后 &key=secret,MD5 后转大写。
|
||||
*/
|
||||
private String generateCallBackSign(VmCallBackReq req) {
|
||||
TreeMap<String, Object> params = new TreeMap<>();
|
||||
|
||||
if (req.getMchNo() != null && !req.getMchNo().isEmpty()) {
|
||||
params.put("mchNo", req.getMchNo());
|
||||
}
|
||||
if (req.getAppId() != null && !req.getAppId().isEmpty()) {
|
||||
params.put("appId", req.getAppId());
|
||||
}
|
||||
if (req.getMchOrderNo() != null && !req.getMchOrderNo().isEmpty()) {
|
||||
params.put("mchOrderNo", req.getMchOrderNo());
|
||||
}
|
||||
if (req.getPayOrderId() != null && !req.getPayOrderId().isEmpty()) {
|
||||
params.put("payOrderId", req.getPayOrderId());
|
||||
}
|
||||
if (req.getIfCode() != null && !req.getIfCode().isEmpty()) {
|
||||
params.put("ifCode", req.getIfCode());
|
||||
}
|
||||
if (req.getWayCode() != null && !req.getWayCode().isEmpty()) {
|
||||
params.put("wayCode", req.getWayCode());
|
||||
}
|
||||
if (req.getAmount() != null) {
|
||||
params.put("amount", req.getAmount());
|
||||
}
|
||||
if (req.getCurrency() != null && !req.getCurrency().isEmpty()) {
|
||||
params.put("currency", req.getCurrency());
|
||||
}
|
||||
if (req.getState() != null) {
|
||||
params.put("state", req.getState());
|
||||
}
|
||||
if (req.getClientIp() != null && !req.getClientIp().isEmpty()) {
|
||||
params.put("clientIp", req.getClientIp());
|
||||
}
|
||||
if (req.getSubject() != null && !req.getSubject().isEmpty()) {
|
||||
params.put("subject", req.getSubject());
|
||||
}
|
||||
if (req.getBody() != null && !req.getBody().isEmpty()) {
|
||||
params.put("body", req.getBody());
|
||||
}
|
||||
if (req.getChannelOrderNo() != null && !req.getChannelOrderNo().isEmpty()) {
|
||||
params.put("channelOrderNo", req.getChannelOrderNo());
|
||||
}
|
||||
if (req.getErrCode() != null && !req.getErrCode().isEmpty()) {
|
||||
params.put("errCode", req.getErrCode());
|
||||
}
|
||||
if (req.getErrMsg() != null && !req.getErrMsg().isEmpty()) {
|
||||
params.put("errMsg", req.getErrMsg());
|
||||
}
|
||||
if (req.getExtParam() != null && !req.getExtParam().isEmpty()) {
|
||||
params.put("extParam", req.getExtParam());
|
||||
}
|
||||
if (req.getCreatedAt() != null) {
|
||||
params.put("createdAt", req.getCreatedAt());
|
||||
}
|
||||
if (req.getSuccessTime() != null) {
|
||||
params.put("successTime", req.getSuccessTime());
|
||||
}
|
||||
if (req.getReqTime() != null) {
|
||||
params.put("reqTime", req.getReqTime());
|
||||
}
|
||||
|
||||
StringBuilder stringA = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||
if (stringA.length() > 0) {
|
||||
stringA.append("&");
|
||||
}
|
||||
stringA.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
|
||||
String stringSignTemp = stringA.toString() + "&key=" + secret;
|
||||
log.debug("VM回调签名源字符串: {}", stringSignTemp);
|
||||
|
||||
String sign = Md5Utils.hash(stringSignTemp).toUpperCase();
|
||||
log.debug("VM回调计算出的签名: {}", sign);
|
||||
return sign;
|
||||
}
|
||||
}
|
||||
|
|
@ -60,37 +60,111 @@ public class YuZhouService implements IYuZhouService {
|
|||
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
String orderNo = dateTime + "yz" + uuid;
|
||||
|
||||
// 获取用户信息
|
||||
AiUser userInfo = aiUserService.getUserInfo(SecurityUtils.getAiUserId());
|
||||
|
||||
// 处理用户姓名,如果没有则使用默认值
|
||||
String firstName = "John";
|
||||
String lastName = "Doe";
|
||||
if (userInfo.getNickname() != null && !userInfo.getNickname().trim().isEmpty()) {
|
||||
String[] nameParts = userInfo.getNickname().trim().split("\\s+");
|
||||
if (nameParts.length >= 2) {
|
||||
firstName = nameParts[0];
|
||||
lastName = nameParts[nameParts.length - 1];
|
||||
} else if (nameParts.length == 1) {
|
||||
firstName = nameParts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 从用户表获取邮箱
|
||||
String email = userInfo.getEmail();
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
log.warn("用户邮箱为空 userId: {}, nickname: {}", userInfo.getId(), userInfo.getNickname());
|
||||
// 如果用户邮箱为空,使用默认邮箱格式
|
||||
email = "user" + userInfo.getId() + "@example.com";
|
||||
} else {
|
||||
email = email.trim();
|
||||
}
|
||||
|
||||
// 处理电话,如果没有则使用默认值
|
||||
String phone = userInfo.getPhone();
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
phone = "1234567890";
|
||||
} else {
|
||||
// 确保电话是10位数字
|
||||
phone = phone.replaceAll("[^0-9]", "");
|
||||
if (phone.length() < 10) {
|
||||
// 如果不足10位,用0补齐
|
||||
phone = String.format("%010d", phone.length() > 0 ? Long.parseLong(phone) : 0);
|
||||
} else if (phone.length() > 10) {
|
||||
// 如果超过10位,取后10位
|
||||
phone = phone.substring(phone.length() - 10);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
Map<String, Object> createMap = new HashMap<>();
|
||||
createMap.put("merchantNo", appId);
|
||||
createMap.put("orderNo", orderNo);
|
||||
createMap.put("money", amount.toString());
|
||||
createMap.put("description", "test");
|
||||
createMap.put("name", "test");
|
||||
createMap.put("email", "test@gmail.com");
|
||||
createMap.put("callbackUrl", callbackUrl + "/api/pay/yuzhou-callBack");
|
||||
createMap.put("phone", "7383442114");
|
||||
createMap.put("description", "Recharge");
|
||||
createMap.put("email", email);
|
||||
createMap.put("phone", phone);
|
||||
createMap.put("notifyUrl", callbackUrl + "/api/pay/yuzhou-callBack");
|
||||
createMap.put("expiredPeriod", "1440");
|
||||
createMap.put("redirectUrl", redirectUrl + "/recharge");
|
||||
createMap.put("currency", "USD");
|
||||
createMap.put("firstName", firstName);
|
||||
createMap.put("lastName", lastName);
|
||||
createMap.put("street", "123 Main Street");
|
||||
createMap.put("city", "New York");
|
||||
createMap.put("state", "NY");
|
||||
createMap.put("country", userInfo.getCountry() != null && !userInfo.getCountry().trim().isEmpty() ? userInfo.getCountry() : "US");
|
||||
createMap.put("postcode", "10001");
|
||||
|
||||
// 生成签名(注意:签名时不包括signature字段)
|
||||
String signedStr = RSAUtils.getSignStr(createMap, secretKey);
|
||||
createMap.put("signature", signedStr);
|
||||
|
||||
String postStr = JSONUtil.toJsonStr(createMap);
|
||||
String response = HttpRequest.post(url + "/gateway/order/US/payIn").header("Content-Type", "application/json")
|
||||
.body(postStr).execute().body();
|
||||
log.info("yuzhou支付请求参数: {}", postStr);
|
||||
|
||||
// 调用新的接口地址
|
||||
String response = HttpRequest.post(url + "/gateway/US/payIn")
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.body(postStr)
|
||||
.execute()
|
||||
.body();
|
||||
|
||||
log.info("yuzhou支付响应: {}", response);
|
||||
|
||||
JSONObject returnObj = JSONUtil.parseObj(response);
|
||||
Integer code = returnObj.getInt("code");
|
||||
if (200 != code) {
|
||||
log.error("yuzhou支付请求的响应异常 {}", returnObj);
|
||||
throw new Exception("yuzhoupay responsebody is null");
|
||||
String msg = returnObj.getStr("msg");
|
||||
log.error("yuzhou支付请求的响应异常 code: {}, msg: {}, response: {}", code, msg, returnObj);
|
||||
throw new ServiceException("yuzhoupay request failed: " + (msg != null ? msg : "unknown error"), code);
|
||||
}
|
||||
|
||||
JSONObject data = returnObj.getJSONObject("data");
|
||||
if (data == null) {
|
||||
log.error("yuzhou支付响应数据为空: {}", returnObj);
|
||||
throw new ServiceException("yuzhoupay response data is null", -1);
|
||||
}
|
||||
|
||||
String money = data.getStr("money");
|
||||
String payUrl = data.getStr("url");
|
||||
String platformOrderNo = data.getStr("platformOrderNo");
|
||||
|
||||
if (payUrl == null || payUrl.trim().isEmpty()) {
|
||||
log.error("yuzhou支付URL为空: {}", data);
|
||||
throw new ServiceException("yuzhoupay url is null", -1);
|
||||
}
|
||||
|
||||
// 创建充值管理
|
||||
AiUser userInfo = aiUserService.getUserInfo(SecurityUtils.getAiUserId());
|
||||
AiRecharge aiRecharge = new AiRecharge();
|
||||
aiRecharge.setOrderNum(orderNo);
|
||||
aiRecharge.setUserId(SecurityUtils.getAiUserId());
|
||||
aiRecharge.setAmount(new BigDecimal(money));
|
||||
aiRecharge.setAmount(new BigDecimal(money != null ? money : amount.toString()));
|
||||
aiRecharge.setGearId(gearId);
|
||||
aiRecharge.setSource(userInfo.getSource());
|
||||
aiRecharge.setGearAmount(aiRechargeGiftGear.getRechargeAmount());
|
||||
|
|
@ -98,6 +172,7 @@ public class YuZhouService implements IYuZhouService {
|
|||
aiRecharge.setPayType(aiRechargeGift.getPayType());
|
||||
aiRecharge.setPayUrl(payUrl);
|
||||
aiRechargeService.insertAiRecharge(aiRecharge);
|
||||
|
||||
PayResVO payResVO = new PayResVO();
|
||||
payResVO.setOrderNo(orderNo);
|
||||
payResVO.setPayUrl(payUrl);
|
||||
|
|
@ -106,31 +181,60 @@ public class YuZhouService implements IYuZhouService {
|
|||
|
||||
@Override
|
||||
public String callBack(Map<String, Object> map) throws Exception {
|
||||
int code = Integer.parseInt(map.get("status").toString());
|
||||
String orderNo = map.get("orderNo").toString();
|
||||
if (10 != code) {
|
||||
log.error("yuzhou支付失败 {}", orderNo);
|
||||
return null;
|
||||
log.info("yuzhou支付回调接收参数: {}", map);
|
||||
|
||||
// 验证必要参数
|
||||
if (map == null || map.isEmpty()) {
|
||||
log.error("yuzhou支付回调参数为空");
|
||||
return "FAIL";
|
||||
}
|
||||
// Map<String, Object> createMap = new HashMap<>();
|
||||
// createMap.put("orderNo", orderNo);
|
||||
// createMap.put("platOrderNo", map.get("platOrderNo"));
|
||||
// createMap.put("money", map.get("money"));
|
||||
// createMap.put("fee", map.get("fee"));
|
||||
// createMap.put("status", map.get("status"));
|
||||
// createMap.put("message", map.get("message"));
|
||||
String signature = map.get("signature").toString();
|
||||
|
||||
Object statusObj = map.get("status");
|
||||
Object orderNoObj = map.get("orderNo");
|
||||
|
||||
if (statusObj == null || orderNoObj == null) {
|
||||
log.error("yuzhou支付回调缺少必要参数: status={}, orderNo={}", statusObj, orderNoObj);
|
||||
return "FAIL";
|
||||
}
|
||||
|
||||
int status = Integer.parseInt(statusObj.toString());
|
||||
String orderNo = orderNoObj.toString();
|
||||
|
||||
log.info("yuzhou支付回调 orderNo: {}, status: {}", orderNo, status);
|
||||
|
||||
// 验证签名
|
||||
Object signatureObj = map.get("signature");
|
||||
if (signatureObj == null) {
|
||||
log.error("yuzhou支付回调缺少签名参数 orderNo: {}", orderNo);
|
||||
return "FAIL";
|
||||
}
|
||||
|
||||
JSONObject entries = JSONUtil.parseObj(map);
|
||||
boolean b = RSAUtils.verifySign(entries, publicKey);
|
||||
if (!b) {
|
||||
log.error("yuzhou支付回调签名错误 {}", orderNo);
|
||||
log.error("yuzhou支付回调签名 {}", signature);
|
||||
log.error("yuzhou支付回调报文 {}", map);
|
||||
return null;
|
||||
boolean verifyResult = RSAUtils.verifySign(entries, publicKey);
|
||||
if (!verifyResult) {
|
||||
log.error("yuzhou支付回调签名验证失败 orderNo: {}", orderNo);
|
||||
log.error("yuzhou支付回调签名: {}", signatureObj);
|
||||
log.error("yuzhou支付回调完整报文: {}", map);
|
||||
return "FAIL";
|
||||
}
|
||||
|
||||
log.info("yuzhou支付回调签名验证成功 orderNo: {}", orderNo);
|
||||
|
||||
// 判断订单状态,10表示成功
|
||||
if (10 != status) {
|
||||
log.warn("yuzhou支付订单状态非成功 orderNo: {}, status: {}", orderNo, status);
|
||||
return "SUCCESS"; // 即使失败也返回SUCCESS,避免重复回调
|
||||
}
|
||||
|
||||
// 充值成功处理
|
||||
try {
|
||||
aiRechargeService.addRecharge(orderNo);
|
||||
log.info("yuzhou支付回调处理成功 orderNo: {}", orderNo);
|
||||
return "SUCCESS";
|
||||
} catch (Exception e) {
|
||||
log.error("yuzhou支付回调处理失败 orderNo: {}", orderNo, e);
|
||||
return "FAIL";
|
||||
}
|
||||
//充值成功处理
|
||||
aiRechargeService.addRecharge(orderNo);
|
||||
return "SUCCESS";
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<result property="source" column="source" />
|
||||
<result property="text" column="text" />
|
||||
<result property="isTop" column="is_top" />
|
||||
<result property="img1" column="img1" />
|
||||
<result property="img2" column="img2" />
|
||||
<result property="mode" column="mode" />
|
||||
<result property="duration" column="duration" />
|
||||
<result property="resolution" column="resolution" />
|
||||
<result property="ratio" column="ratio" />
|
||||
<result property="model" column="model" />
|
||||
<result property="videoParams" column="video_params" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectAiOrderVo">
|
||||
select ao.id, ao.del_flag, ao.create_by, ao.create_time, ao.update_by, ao.update_time, ao.remark, ao.order_num, ao.user_id, ao.type, ao.amount, ao.result, ao.status, ao.source, ao.text, ao.is_top, au.user_id uuid from ai_order ao
|
||||
select ao.id, ao.del_flag, ao.create_by, ao.create_time, ao.update_by, ao.update_time, ao.remark, ao.order_num, ao.user_id, ao.type, ao.amount, ao.result, ao.status, ao.source, ao.text, ao.is_top, ao.img1, ao.img2, ao.mode, ao.duration, ao.resolution, ao.ratio, ao.model, ao.video_params, au.user_id uuid from ai_order ao
|
||||
left join ai_user au on au.id = ao.user_id
|
||||
</sql>
|
||||
|
||||
|
|
@ -61,6 +69,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
where result = #{result}
|
||||
</select>
|
||||
|
||||
<select id="getAiOrderByPortalVideoTask" resultMap="AiOrderResult">
|
||||
<include refid="selectAiOrderVo"/>
|
||||
where ao.del_flag = '0'
|
||||
and ao.type = '21'
|
||||
and (
|
||||
ao.result = #{taskId}
|
||||
or (
|
||||
ao.video_params is not null and ao.video_params != ''
|
||||
and JSON_VALID(ao.video_params)
|
||||
and JSON_UNQUOTE(JSON_EXTRACT(ao.video_params, '$.volcTaskId')) = #{taskId}
|
||||
)
|
||||
)
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="getSumAmountByUserId" resultType="java.math.BigDecimal">
|
||||
SELECT COALESCE(sum(amount), 0) from ai_order where user_id = #{userId}
|
||||
</select>
|
||||
|
|
@ -118,7 +141,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="amount != null">amount = #{amount},</if>
|
||||
<if test="result != null">result = #{result},</if>
|
||||
<if test="status != null">status = #{status},</if>
|
||||
<if test="source != null">status = #{source},</if>
|
||||
<if test="source != null">source = #{source},</if>
|
||||
<if test="text != null">text = #{text},</if>
|
||||
<if test="isTop != null">is_top = #{isTop},</if>
|
||||
</trim>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<result property="chineseContent" column="chinese_content" />
|
||||
<result property="englishContent" column="english_content" />
|
||||
<result property="imageUrl" column="image_url" />
|
||||
<result property="aiId" column="ai_id" />
|
||||
<result property="status" column="status" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="createTime" column="create_time" />
|
||||
|
|
@ -20,7 +21,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
</resultMap>
|
||||
|
||||
<sql id="selectAiTemplateVo">
|
||||
select id, name, chinese_content, english_content, image_url, status, remark, create_time, create_by, update_by, update_time, del_flag from ai_template
|
||||
select id, name, chinese_content, english_content, image_url, ai_id, status, remark, create_time, create_by, update_by, update_time, del_flag from ai_template
|
||||
</sql>
|
||||
|
||||
<select id="selectAiTemplateList" parameterType="AiTemplate" resultMap="AiTemplateResult">
|
||||
|
|
@ -30,6 +31,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="chineseContent != null and chineseContent != ''"> and chinese_content = #{chineseContent}</if>
|
||||
<if test="englishContent != null and englishContent != ''"> and english_content = #{englishContent}</if>
|
||||
<if test="imageUrl != null and imageUrl != ''"> and image_url = #{imageUrl}</if>
|
||||
<if test="aiId != null and aiId != ''"> and ai_id = #{aiId}</if>
|
||||
<if test="status != null "> and status = #{status}</if>
|
||||
</where>
|
||||
</select>
|
||||
|
|
@ -46,6 +48,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="chineseContent != null">chinese_content,</if>
|
||||
<if test="englishContent != null">english_content,</if>
|
||||
<if test="imageUrl != null">image_url,</if>
|
||||
<if test="aiId != null">ai_id,</if>
|
||||
<if test="status != null">status,</if>
|
||||
<if test="remark != null">remark,</if>
|
||||
<if test="createTime != null">create_time,</if>
|
||||
|
|
@ -59,6 +62,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="chineseContent != null">#{chineseContent},</if>
|
||||
<if test="englishContent != null">#{englishContent},</if>
|
||||
<if test="imageUrl != null">#{imageUrl},</if>
|
||||
<if test="aiId != null">#{aiId},</if>
|
||||
<if test="status != null">#{status},</if>
|
||||
<if test="remark != null">#{remark},</if>
|
||||
<if test="createTime != null">#{createTime},</if>
|
||||
|
|
@ -76,6 +80,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="chineseContent != null">chinese_content = #{chineseContent},</if>
|
||||
<if test="englishContent != null">english_content = #{englishContent},</if>
|
||||
<if test="imageUrl != null">image_url = #{imageUrl},</if>
|
||||
<if test="aiId != null">ai_id = #{aiId},</if>
|
||||
<if test="status != null">status = #{status},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
<if test="createTime != null">create_time = #{createTime},</if>
|
||||
|
|
|
|||
|
|
@ -33,15 +33,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<result property="source" column="source" />
|
||||
<result property="ip" column="ip" />
|
||||
<result property="country" column="country" />
|
||||
<result property="deptId" column="dept_id" />
|
||||
<result property="deptName" column="dept_name" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectAiUserVo">
|
||||
select id, del_flag, create_by, create_time, update_by, update_time, remark, username, nickname, gender, avatar, phone, password, openid, status, email, birthday, invitation_code, payment_url, login_time, balance, superior_id, user_id, source, ip, country from ai_user
|
||||
select id, del_flag, create_by, create_time, update_by, update_time, remark, username, nickname, gender, avatar, phone, password, openid, status, email, birthday, invitation_code, payment_url, login_time, balance, superior_id, user_id, source, ip, country, dept_id from ai_user
|
||||
</sql>
|
||||
|
||||
<select id="selectAiUserList" parameterType="AiUser" resultMap="AiUserResult">
|
||||
select u.id, u.del_flag, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, u.username, u.nickname, u.gender, u.avatar, u.phone, u.password, u.openid, u.status, u.email, u.birthday, u.invitation_code, u.payment_url, u.login_time, u.balance, u.superior_id, u.user_id, u.source, u.ip, u.country, au.user_id superiorUuid, au.username superiorName from ai_user u
|
||||
select u.id, u.del_flag, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, u.username, u.nickname, u.gender, u.avatar, u.phone, u.password, u.openid, u.status, u.email, u.birthday, u.invitation_code, u.payment_url, u.login_time, u.balance, u.superior_id, u.user_id, u.source, u.ip, u.country, u.dept_id, d.dept_name, au.user_id superiorUuid, au.username superiorName from ai_user u
|
||||
left join ai_user au on au.id = u.superior_id
|
||||
left join sys_dept d on d.dept_id = u.dept_id and d.del_flag = '0'
|
||||
<where>
|
||||
<if test="nickname != null and nickname != ''"> and u.nickname like concat('%', #{nickname}, '%')</if>
|
||||
<if test="username != null and username != ''"> and u.username like concat('%', #{username}, '%')</if>
|
||||
|
|
@ -63,19 +66,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="superiorId != null "> and u.superior_id = #{superiorId}</if>
|
||||
<if test="id != null "> and u.id = #{id}</if>
|
||||
<if test="source != null "> and u.source = #{source}</if>
|
||||
<if test="deptId != null "> and u.dept_id = #{deptId}</if>
|
||||
</where>
|
||||
order by u.id desc
|
||||
</select>
|
||||
|
||||
<select id="selectAiUserById" parameterType="Long" resultMap="AiUserResult">
|
||||
<include refid="selectAiUserVo"/>
|
||||
where id = #{id}
|
||||
select u.id, u.del_flag, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, u.username, u.nickname, u.gender, u.avatar, u.phone, u.password, u.openid, u.status, u.email, u.birthday, u.invitation_code, u.payment_url, u.login_time, u.balance, u.superior_id, u.user_id, u.source, u.ip, u.country, u.dept_id, d.dept_name
|
||||
from ai_user u
|
||||
left join sys_dept d on d.dept_id = u.dept_id and d.del_flag = '0'
|
||||
where u.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="getUserInfo" parameterType="Long" resultMap="AiUserResult">
|
||||
select del_flag, create_by, create_time, update_by, update_time, remark, username, nickname, gender, avatar, phone, password, openid, status, email, birthday, invitation_code, payment_url, login_time, balance, superior_id, user_id, source from ai_user
|
||||
select del_flag, create_by, create_time, update_by, update_time, remark, username, nickname, gender, avatar, phone, password, openid, status, email, birthday, invitation_code, payment_url, login_time, balance, superior_id, user_id, source, ip, country, dept_id from ai_user
|
||||
where id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="countAiUserByDeptId" resultType="int">
|
||||
select count(1) from ai_user where del_flag = '0' and dept_id = #{deptId}
|
||||
</select>
|
||||
<select id="selectPasswordById" resultType="java.lang.String">
|
||||
select password from ai_user where id = #{id}
|
||||
</select>
|
||||
|
|
@ -108,6 +118,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="source != null">source,</if>
|
||||
<if test="ip != null">ip,</if>
|
||||
<if test="country != null">country,</if>
|
||||
<if test="deptId != null">dept_id,</if>
|
||||
</trim>
|
||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
|
||||
|
|
@ -134,6 +145,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="userId != null">#{userId},</if>
|
||||
<if test="source != null">#{source},</if>
|
||||
<if test="country != null">#{country},</if>
|
||||
<if test="deptId != null">#{deptId},</if>
|
||||
</trim>
|
||||
</insert>
|
||||
|
||||
|
|
@ -163,8 +175,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="superiorId != null">superior_id = #{superiorId},</if>
|
||||
<if test="userId != null">user_id = #{userId},</if>
|
||||
<if test="source != null">source = #{source},</if>
|
||||
<if test="ip != null">source = #{ip},</if>
|
||||
<if test="ip != null">ip = #{ip},</if>
|
||||
<if test="country != null">country = #{country},</if>
|
||||
<if test="deptId != null">dept_id = #{deptId},</if>
|
||||
</trim>
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<result property="leader" column="leader" />
|
||||
<result property="phone" column="phone" />
|
||||
<result property="email" column="email" />
|
||||
<result property="byteApiKey" column="byte_api_key" />
|
||||
<result property="status" column="status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="parentName" column="parent_name" />
|
||||
|
|
@ -23,7 +24,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
</resultMap>
|
||||
|
||||
<sql id="selectDeptVo">
|
||||
select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.status, d.del_flag, d.create_by, d.create_time
|
||||
select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.byte_api_key, d.status, d.del_flag, d.create_by, d.create_time
|
||||
from sys_dept d
|
||||
</sql>
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
</select>
|
||||
|
||||
<select id="selectDeptById" parameterType="Long" resultMap="SysDeptResult">
|
||||
select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.status,
|
||||
select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.byte_api_key, d.status,
|
||||
(select dept_name from sys_dept where dept_id = d.parent_id) parent_name
|
||||
from sys_dept d
|
||||
where d.dept_id = #{deptId}
|
||||
|
|
@ -101,6 +102,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="leader != null and leader != ''">leader,</if>
|
||||
<if test="phone != null and phone != ''">phone,</if>
|
||||
<if test="email != null and email != ''">email,</if>
|
||||
<if test="byteApiKey != null">byte_api_key,</if>
|
||||
<if test="status != null">status,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
create_time
|
||||
|
|
@ -113,6 +115,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="leader != null and leader != ''">#{leader},</if>
|
||||
<if test="phone != null and phone != ''">#{phone},</if>
|
||||
<if test="email != null and email != ''">#{email},</if>
|
||||
<if test="byteApiKey != null">#{byteApiKey},</if>
|
||||
<if test="status != null">#{status},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
sysdate()
|
||||
|
|
@ -129,6 +132,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||
<if test="leader != null">leader = #{leader},</if>
|
||||
<if test="phone != null">phone = #{phone},</if>
|
||||
<if test="email != null">email = #{email},</if>
|
||||
<if test="byteApiKey != null">byte_api_key = #{byteApiKey},</if>
|
||||
<if test="status != null and status != ''">status = #{status},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
update_time = sysdate()
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue