fix: 客户定制需求,对接三方zhonglian
This commit is contained in:
parent
9250253238
commit
19adf685e0
|
|
@ -64,38 +64,20 @@
|
||||||
|
|
||||||
<div class="vg-compose-mod vg-compose-mod--asset">
|
<div class="vg-compose-mod vg-compose-mod--asset">
|
||||||
<div class="vg-mod-body vg-mod-body--asset">
|
<div class="vg-mod-body vg-mod-body--asset">
|
||||||
<a-select
|
<span class="vg-asset-btn-wrap">
|
||||||
v-model="assetGroupId"
|
|
||||||
class="vg-asset-group-input"
|
|
||||||
placeholder="请选择素材组"
|
|
||||||
allow-clear
|
|
||||||
:loading="groupLoading || groupLoadingMore"
|
|
||||||
:disabled="groupLoading && assetGroups.length === 0"
|
|
||||||
@dropdown-reach-bottom="onAssetGroupDropdownReachBottom">
|
|
||||||
<a-option v-for="g in assetGroups" :key="g.value" :value="g.value">
|
|
||||||
{{ g.label }}
|
|
||||||
</a-option>
|
|
||||||
</a-select>
|
|
||||||
<span
|
|
||||||
class="vg-asset-btn-wrap"
|
|
||||||
:title="!hasAssetGroupId ? '请先选择素材组' : ''">
|
|
||||||
<mf-button
|
<mf-button
|
||||||
class="vg-compose-left-upload vg-mod-btn"
|
class="vg-compose-left-upload vg-mod-btn"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!hasAssetGroupId"
|
|
||||||
:loading="assetLoading"
|
:loading="assetLoading"
|
||||||
@click="loadAssetsByGroup">
|
@click="loadTosAssetsForPicker">
|
||||||
选择资产
|
选择资产
|
||||||
</mf-button>
|
</mf-button>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span class="vg-asset-btn-wrap">
|
||||||
class="vg-asset-btn-wrap"
|
|
||||||
:title="!hasAssetGroupId ? '请先选择素材组' : ''">
|
|
||||||
<mf-button
|
<mf-button
|
||||||
class="vg-compose-left-upload vg-mod-btn"
|
class="vg-compose-left-upload vg-mod-btn"
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!hasAssetGroupId"
|
|
||||||
@click="openFilePickerForAssetUpload">
|
@click="openFilePickerForAssetUpload">
|
||||||
上传资产
|
上传资产
|
||||||
</mf-button>
|
</mf-button>
|
||||||
|
|
@ -237,11 +219,11 @@
|
||||||
:ok-button-props="{ disabled: !assetPickerSelectedKeys.length }"
|
:ok-button-props="{ disabled: !assetPickerSelectedKeys.length }"
|
||||||
@ok="confirmPickAssets">
|
@ok="confirmPickAssets">
|
||||||
<div class="vg-asset-picker">
|
<div class="vg-asset-picker">
|
||||||
<div v-if="assetQueryResults.length === 0" class="vg-asset-picker-empty">当前分组暂无可用素材</div>
|
<div v-if="assetQueryResults.length === 0" class="vg-asset-picker-empty">暂无三方素材,请先上传并过审</div>
|
||||||
<label v-for="it in assetQueryResults" :key="it.assetId || it.id" class="vg-asset-picker-item">
|
<label v-for="it in assetQueryResults" :key="it.pickKey" class="vg-asset-picker-item">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="String(it.assetId || it.id)"
|
:value="it.pickKey"
|
||||||
v-model="assetPickerSelectedKeys"
|
v-model="assetPickerSelectedKeys"
|
||||||
/>
|
/>
|
||||||
<div class="vg-asset-picker-preview">
|
<div class="vg-asset-picker-preview">
|
||||||
|
|
@ -249,7 +231,7 @@
|
||||||
<video v-else-if="it.mediaType === 'video'" :src="it.url" muted playsinline preload="metadata"></video>
|
<video v-else-if="it.mediaType === 'video'" :src="it.url" muted playsinline preload="metadata"></video>
|
||||||
<div v-else class="vg-audio-tile">♪ 音频</div>
|
<div v-else class="vg-audio-tile">♪ 音频</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vg-asset-picker-name">{{ it.name || it.assetId }}</div>
|
<div class="vg-asset-picker-name" :title="it.name">{{ it.name }}</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
@ -261,24 +243,64 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import { byteApiItems, byteApiTotalCount } from '@/utils/byteAssetApi'
|
|
||||||
|
|
||||||
const ASSET_GROUP_LIST_PAGE_SIZE = 20
|
const TOS_ASSET_LIST_PAGE_SIZE = 200
|
||||||
|
|
||||||
const buildAssetGroupListBody = (pageNumber) => ({
|
/** COS:/api/cos/upload + pathPrefix=asset,与三方素材管理一致 */
|
||||||
filter: { groupType: 'AIGC' },
|
const uploadViaCosAssetPrefix = async (file) => {
|
||||||
pageNumber,
|
const form = new FormData()
|
||||||
pageSize: ASSET_GROUP_LIST_PAGE_SIZE,
|
form.append('file', file)
|
||||||
sortBy: 'CreateTime',
|
form.append('pathPrefix', 'asset')
|
||||||
sortOrder: 'Desc'
|
const res = await request({
|
||||||
|
url: 'api/cos/upload',
|
||||||
|
method: 'post',
|
||||||
|
data: form,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data;boundary=' + new Date().getTime()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
if (res.code !== 200 || !res.url) {
|
||||||
|
throw new Error(res.msg || '上传失败')
|
||||||
|
}
|
||||||
|
return String(res.url).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeAssetGroupOption = (g) => {
|
const extractModerationItems = (data) => {
|
||||||
const value = String(g?.Id ?? g?.id ?? '').trim()
|
if (!data) return []
|
||||||
const name = String(g?.Name ?? g?.name ?? '').trim()
|
if (Array.isArray(data.items)) return data.items
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapTosItemFromModeration = (it) => {
|
||||||
|
const sourceUrl = it.source_url || it.sourceUrl || ''
|
||||||
|
const assetUrl = it.asset_url || it.assetUrl || ''
|
||||||
|
const assetId = it.asset_id || it.assetId || ''
|
||||||
return {
|
return {
|
||||||
value,
|
id: `tos_${assetId || it.id || Date.now()}`,
|
||||||
label: name || '-'
|
url: sourceUrl || assetUrl,
|
||||||
|
assetUrl,
|
||||||
|
assetId,
|
||||||
|
mediaType: 'image',
|
||||||
|
name: (sourceUrl || assetUrl || '').slice(-48) || '素材'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapTosRowToPickerItem = (row) => {
|
||||||
|
const sourceUrl = String(row.sourceUrl || row.source_url || '').trim()
|
||||||
|
const assetUrl = String(row.assetUrl || row.asset_url || '').trim()
|
||||||
|
const assetId = String(row.assetId || row.asset_id || '').trim()
|
||||||
|
const at = String(row.assetType || row.asset_type || 'Image').toLowerCase()
|
||||||
|
let mediaType = 'image'
|
||||||
|
if (at.includes('video')) mediaType = 'video'
|
||||||
|
else if (at.includes('audio')) mediaType = 'audio'
|
||||||
|
return {
|
||||||
|
pickKey: assetUrl || `id:${row.id}`,
|
||||||
|
id: row.id,
|
||||||
|
url: sourceUrl || assetUrl,
|
||||||
|
assetUrl,
|
||||||
|
assetId,
|
||||||
|
mediaType,
|
||||||
|
name: sourceUrl || assetUrl || assetId || '-'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,13 +344,7 @@ const currentUploadIndex = ref(-1) // for first/last frame mode
|
||||||
const savedSelectionRange = ref(null)
|
const savedSelectionRange = ref(null)
|
||||||
const mentionVisible = ref(false)
|
const mentionVisible = ref(false)
|
||||||
const mentionActiveIndex = ref(-1)
|
const mentionActiveIndex = ref(-1)
|
||||||
const assetGroupId = ref('')
|
|
||||||
const assetLoading = ref(false)
|
const assetLoading = ref(false)
|
||||||
const groupLoading = ref(false)
|
|
||||||
const groupLoadingMore = ref(false)
|
|
||||||
const assetGroupPageNumber = ref(0)
|
|
||||||
const assetGroupHasMore = ref(false)
|
|
||||||
const assetGroups = ref([])
|
|
||||||
const assetPickerVisible = ref(false)
|
const assetPickerVisible = ref(false)
|
||||||
const assetQueryResults = ref([])
|
const assetQueryResults = ref([])
|
||||||
const assetPickerSelectedKeys = ref([])
|
const assetPickerSelectedKeys = ref([])
|
||||||
|
|
@ -370,13 +386,15 @@ const mentionCandidates = computed(() =>
|
||||||
const u = String(i?.url || '').trim()
|
const u = String(i?.url || '').trim()
|
||||||
const t = i?.mediaType
|
const t = i?.mediaType
|
||||||
if (!['image', 'video', 'audio'].includes(t) || i?.isUploading) return false
|
if (!['image', 'video', 'audio'].includes(t) || i?.isUploading) return false
|
||||||
if (!i?.assetId && !/^https?:\/\//i.test(u)) return false
|
const hasAssetRef = !!(i?.assetId || i?.assetUrl)
|
||||||
|
if (!hasAssetRef && !/^https?:\/\//i.test(u)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map((i, idx) => ({
|
.map((i, idx) => ({
|
||||||
key: i.id || i.url || String(idx),
|
key: i.id || i.url || String(idx),
|
||||||
url: i.url,
|
url: i.url,
|
||||||
assetId: i.assetId || '',
|
assetId: i.assetId || '',
|
||||||
|
assetUrl: i.assetUrl || '',
|
||||||
mediaType: i.mediaType || 'image',
|
mediaType: i.mediaType || 'image',
|
||||||
name: i.name || ''
|
name: i.name || ''
|
||||||
}))
|
}))
|
||||||
|
|
@ -387,9 +405,6 @@ const isFirstFrame = computed(() => props.videoMode === 'image-first-frame')
|
||||||
const isFirstLastFrame = computed(() => props.videoMode === 'image-first-last-frame')
|
const isFirstLastFrame = computed(() => props.videoMode === 'image-first-last-frame')
|
||||||
const isReference = computed(() => props.videoMode === 'image-reference')
|
const isReference = computed(() => props.videoMode === 'image-reference')
|
||||||
|
|
||||||
/** 参考图模式:已选素材组 ID(与素材管理页一致,非空才可选择/上传资产) */
|
|
||||||
const hasAssetGroupId = computed(() => !!String(assetGroupId.value || '').trim())
|
|
||||||
|
|
||||||
const setPrompt = (next) => {
|
const setPrompt = (next) => {
|
||||||
localPrompt.value = next
|
localPrompt.value = next
|
||||||
emit('update:modelValue', next)
|
emit('update:modelValue', next)
|
||||||
|
|
@ -400,23 +415,6 @@ const setMediaList = (next) => {
|
||||||
emit('update:mediaList', next)
|
emit('update:mediaList', next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 与 AssetManage.buildListPayload 结构一致,用于当前素材组下列出素材 */
|
|
||||||
const buildReferenceListAssetsPayload = () => {
|
|
||||||
const gid = String(assetGroupId.value || '').trim()
|
|
||||||
const filter = {
|
|
||||||
groupType: 'AIGC',
|
|
||||||
groupIds: [gid],
|
|
||||||
statuses: ['Active']
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
filter,
|
|
||||||
pageNumber: 1,
|
|
||||||
pageSize: 100,
|
|
||||||
sortBy: 'CreateTime',
|
|
||||||
sortOrder: 'Desc'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onReferenceEmptyAreaClick = () => {
|
const onReferenceEmptyAreaClick = () => {
|
||||||
openReferenceDirectUpload()
|
openReferenceDirectUpload()
|
||||||
}
|
}
|
||||||
|
|
@ -429,12 +427,8 @@ const openReferenceDirectUpload = () => {
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅「上传资产」:COS + createAsset 并写入素材组 */
|
/** 仅「上传资产」:/api/cos/upload(pathPrefix=asset) + /api/tos/asset,与三方素材管理一致 */
|
||||||
const openFilePickerForAssetUpload = () => {
|
const openFilePickerForAssetUpload = () => {
|
||||||
if (!hasAssetGroupId.value) {
|
|
||||||
Message.warning('请先选择素材组')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createAssetIntent.value = true
|
createAssetIntent.value = true
|
||||||
currentUploadIndex.value = -1
|
currentUploadIndex.value = -1
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
|
|
@ -521,10 +515,12 @@ const clearAll = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeAssetsToMediaList = (assets) => {
|
const mergeAssetsToMediaList = (assets) => {
|
||||||
const existing = new Set((mediaList.value || []).map((x) => x.assetId || x.url))
|
const existing = new Set(
|
||||||
|
(mediaList.value || []).map((x) => String(x.assetUrl || x.assetId || x.url || '').trim())
|
||||||
|
)
|
||||||
const next = [...mediaList.value]
|
const next = [...mediaList.value]
|
||||||
for (const item of assets) {
|
for (const item of assets) {
|
||||||
const key = item.assetId || item.url
|
const key = String(item.assetUrl || item.assetId || item.url || '').trim()
|
||||||
if (!key || existing.has(key)) continue
|
if (!key || existing.has(key)) continue
|
||||||
next.push(item)
|
next.push(item)
|
||||||
existing.add(key)
|
existing.add(key)
|
||||||
|
|
@ -533,52 +529,6 @@ const mergeAssetsToMediaList = (assets) => {
|
||||||
setMediaList(next.slice(0, props.maxMediaCount))
|
setMediaList(next.slice(0, props.maxMediaCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAssetGroups = async (reset = true) => {
|
|
||||||
if (reset) {
|
|
||||||
groupLoading.value = true
|
|
||||||
assetGroupPageNumber.value = 0
|
|
||||||
assetGroups.value = []
|
|
||||||
assetGroupHasMore.value = false
|
|
||||||
} else {
|
|
||||||
if (!assetGroupHasMore.value || groupLoadingMore.value || groupLoading.value) return
|
|
||||||
groupLoadingMore.value = true
|
|
||||||
}
|
|
||||||
const nextPage = reset ? 1 : assetGroupPageNumber.value + 1
|
|
||||||
try {
|
|
||||||
const res = await request({
|
|
||||||
url: 'api/byteAssetGroup/listAssetGroups',
|
|
||||||
method: 'POST',
|
|
||||||
data: buildAssetGroupListBody(nextPage)
|
|
||||||
})
|
|
||||||
const payload = res?.data
|
|
||||||
const rawRows = byteApiItems(payload)
|
|
||||||
const rows = rawRows.map(normalizeAssetGroupOption).filter((x) => x.value)
|
|
||||||
const total = byteApiTotalCount(payload)
|
|
||||||
if (reset) {
|
|
||||||
assetGroups.value = rows
|
|
||||||
} else {
|
|
||||||
const seen = new Set(assetGroups.value.map((x) => x.value))
|
|
||||||
for (const r of rows) {
|
|
||||||
if (!seen.has(r.value)) {
|
|
||||||
assetGroups.value.push(r)
|
|
||||||
seen.add(r.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assetGroupPageNumber.value = nextPage
|
|
||||||
assetGroupHasMore.value = assetGroups.value.length < total && rows.length > 0
|
|
||||||
if (!assetGroupId.value && assetGroups.value.length) {
|
|
||||||
assetGroupId.value = assetGroups.value[0].value
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
Message.error(err?.message || '加载素材组失败')
|
|
||||||
} finally {
|
|
||||||
groupLoading.value = false
|
|
||||||
groupLoadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 须在 loadReferenceFromStorage / loadAssetGroups 定义之后注册:immediate 会同步执行回调,否则 const 未初始化会抛错,导致从不请求 listAssetGroups
|
|
||||||
watch(
|
watch(
|
||||||
() => props.videoMode,
|
() => props.videoMode,
|
||||||
(newMode) => {
|
(newMode) => {
|
||||||
|
|
@ -589,55 +539,36 @@ watch(
|
||||||
internalMediaList.value = saved
|
internalMediaList.value = saved
|
||||||
emit('update:mediaList', saved)
|
emit('update:mediaList', saved)
|
||||||
}
|
}
|
||||||
loadAssetGroups(true)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAssetGroupDropdownReachBottom = () => {
|
const loadTosAssetsForPicker = async () => {
|
||||||
loadAssetGroups(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadAssetsByGroup = async () => {
|
|
||||||
const gid = String(assetGroupId.value || '').trim()
|
|
||||||
if (!gid) {
|
|
||||||
Message.warning('请先选择素材组')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assetLoading.value = true
|
assetLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await request({
|
const res = await request({
|
||||||
url: 'api/byteAsset/listAssets',
|
url: 'api/tos/asset/list',
|
||||||
method: 'POST',
|
method: 'get',
|
||||||
data: buildReferenceListAssetsPayload()
|
data: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: TOS_ASSET_LIST_PAGE_SIZE
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (res.code !== 200) {
|
if (res.code !== 200) {
|
||||||
Message.error(res.msg || '查询素材失败')
|
Message.error(res.msg || '查询三方素材失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rows = byteApiItems(res?.data)
|
const rows = res.rows || []
|
||||||
const assets = rows
|
const mapped = rows
|
||||||
.map((row) => {
|
.map(mapTosRowToPickerItem)
|
||||||
const at = String(row?.AssetType || row?.assetType || 'Image').toLowerCase()
|
.filter((x) => x.assetUrl && x.url && /^https?:\/\//i.test(String(x.url || '').trim()))
|
||||||
let mediaType = 'image'
|
assetQueryResults.value = mapped
|
||||||
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()))
|
|
||||||
assetQueryResults.value = assets
|
|
||||||
assetPickerSelectedKeys.value = []
|
assetPickerSelectedKeys.value = []
|
||||||
assetPickerVisible.value = true
|
assetPickerVisible.value = true
|
||||||
Message.success(`查询到 ${assets.length} 条可用素材`)
|
Message.success(`可选取 ${mapped.length} 条三方素材`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Message.error(err?.message || '查询素材失败')
|
Message.error(err?.message || '查询三方素材失败')
|
||||||
} finally {
|
} finally {
|
||||||
assetLoading.value = false
|
assetLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -645,9 +576,7 @@ const loadAssetsByGroup = async () => {
|
||||||
|
|
||||||
const confirmPickAssets = () => {
|
const confirmPickAssets = () => {
|
||||||
const pickedKeys = new Set((assetPickerSelectedKeys.value || []).map((x) => String(x)))
|
const pickedKeys = new Set((assetPickerSelectedKeys.value || []).map((x) => String(x)))
|
||||||
const picked = (assetQueryResults.value || []).filter((x) =>
|
const picked = (assetQueryResults.value || []).filter((x) => pickedKeys.has(String(x.pickKey)))
|
||||||
pickedKeys.has(String(x.assetId || x.id))
|
|
||||||
)
|
|
||||||
mergeAssetsToMediaList(picked)
|
mergeAssetsToMediaList(picked)
|
||||||
assetPickerVisible.value = false
|
assetPickerVisible.value = false
|
||||||
assetPickerSelectedKeys.value = []
|
assetPickerSelectedKeys.value = []
|
||||||
|
|
@ -658,10 +587,6 @@ const confirmPickAssets = () => {
|
||||||
|
|
||||||
const isReferenceMode = isReference.value
|
const isReferenceMode = isReference.value
|
||||||
const wantCreateAsset = isReferenceMode && options.createAsset === true
|
const wantCreateAsset = isReferenceMode && options.createAsset === true
|
||||||
if (wantCreateAsset && !String(assetGroupId.value || '').trim()) {
|
|
||||||
Message.warning('请先选择素材组')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let targetList = [...mediaList.value]
|
let targetList = [...mediaList.value]
|
||||||
|
|
||||||
if (isFirstLastFrame.value && currentUploadIndex.value >= 0) {
|
if (isFirstLastFrame.value && currentUploadIndex.value >= 0) {
|
||||||
|
|
@ -726,6 +651,65 @@ const confirmPickAssets = () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const toUpload = targetList.filter((item) => item.isUploading)
|
const toUpload = targetList.filter((item) => item.isUploading)
|
||||||
|
if (wantCreateAsset && toUpload.length) {
|
||||||
|
const cosUrls = []
|
||||||
|
for (const entry of toUpload) {
|
||||||
|
try {
|
||||||
|
const cosUrl = await uploadViaCosAssetPrefix(entry._fileRef)
|
||||||
|
cosUrls.push({ entry, cosUrl })
|
||||||
|
} catch (e) {
|
||||||
|
setMediaList(mediaList.value.filter((x) => normalizeItemKey(x) !== normalizeItemKey(entry)))
|
||||||
|
Message.error(e?.message || '上传失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const modRes = await request({
|
||||||
|
url: 'api/tos/asset',
|
||||||
|
method: 'post',
|
||||||
|
data: { images: cosUrls.map((x) => x.cosUrl) }
|
||||||
|
})
|
||||||
|
if (modRes.code !== 200) {
|
||||||
|
throw new Error(modRes.msg || '提交审核失败')
|
||||||
|
}
|
||||||
|
const items = extractModerationItems(modRes.data)
|
||||||
|
for (let i = 0; i < cosUrls.length; i++) {
|
||||||
|
const { entry, cosUrl } = cosUrls[i]
|
||||||
|
const it = items[i]
|
||||||
|
const mapped = it
|
||||||
|
? mapTosItemFromModeration(it)
|
||||||
|
: {
|
||||||
|
id: `tos_${Date.now()}_${i}`,
|
||||||
|
url: cosUrl,
|
||||||
|
assetUrl: '',
|
||||||
|
assetId: '',
|
||||||
|
mediaType: entry.mediaType || 'image',
|
||||||
|
name: entry.name || ''
|
||||||
|
}
|
||||||
|
const localPreview = entry.url
|
||||||
|
setMediaList(
|
||||||
|
mediaList.value.map((x) =>
|
||||||
|
normalizeItemKey(x) === normalizeItemKey(entry)
|
||||||
|
? { ...mapped, isUploading: false }
|
||||||
|
: x
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(localPreview)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (isReferenceMode) {
|
||||||
|
Message.success('已上传并提交三方审核')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
for (const { entry } of cosUrls) {
|
||||||
|
setMediaList(mediaList.value.filter((x) => normalizeItemKey(x) !== normalizeItemKey(entry)))
|
||||||
|
}
|
||||||
|
Message.error(err?.message || '审核提交失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of toUpload) {
|
for (const entry of toUpload) {
|
||||||
try {
|
try {
|
||||||
const res = await uploadFile({
|
const res = await uploadFile({
|
||||||
|
|
@ -735,41 +719,17 @@ const confirmPickAssets = () => {
|
||||||
})
|
})
|
||||||
const url = extractUploadUrlFromResponse(res)
|
const url = extractUploadUrlFromResponse(res)
|
||||||
if (!url) throw new Error(res?.msg || '未返回文件地址')
|
if (!url) throw new Error(res?.msg || '未返回文件地址')
|
||||||
let assetId = entry.assetId || ''
|
|
||||||
if (wantCreateAsset) {
|
|
||||||
const gid = String(assetGroupId.value || '').trim()
|
|
||||||
if (!gid) throw new Error('请先选择素材组')
|
|
||||||
const mt = entry.mediaType || 'image'
|
|
||||||
const assetType =
|
|
||||||
mt === 'video' ? 'Video' : mt === 'audio' ? 'Audio' : 'Image'
|
|
||||||
const createRes = await request({
|
|
||||||
url: 'api/byteAsset/createAsset',
|
|
||||||
method: 'POST',
|
|
||||||
// 后端新增:通过 url JSON 创建素材,避免再次上传文件
|
|
||||||
data: {
|
|
||||||
URL: url,
|
|
||||||
groupId: gid,
|
|
||||||
assetType,
|
|
||||||
name: entry?.name || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (createRes.code !== 200) {
|
|
||||||
throw new Error(createRes?.msg || '创建素材失败')
|
|
||||||
}
|
|
||||||
assetId = createRes?.data?.Id || createRes?.data?.id || ''
|
|
||||||
if (!assetId) throw new Error(createRes?.msg || '创建素材失败:未返回资产ID')
|
|
||||||
}
|
|
||||||
|
|
||||||
const localPreview = entry.url
|
const localPreview = entry.url
|
||||||
setMediaList(
|
setMediaList(
|
||||||
mediaList.value.map((x) =>
|
mediaList.value.map((x) =>
|
||||||
normalizeItemKey(x) === normalizeItemKey(entry)
|
normalizeItemKey(x) === normalizeItemKey(entry)
|
||||||
? { ...x, url, assetId, isUploading: false }
|
? { ...x, url, assetId: entry.assetId || '', isUploading: false }
|
||||||
: x
|
: x
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (isReferenceMode) {
|
if (isReferenceMode) {
|
||||||
Message.success(wantCreateAsset ? '已上传并写入素材库' : '已上传完成')
|
Message.success('已上传完成')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -987,9 +947,10 @@ const getEditorPlainText = () => {
|
||||||
|
|
||||||
const refKeyForEl = (el) => {
|
const refKeyForEl = (el) => {
|
||||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||||
|
const assetUrl = (el.getAttribute('data-reference-asset-url') || '').trim()
|
||||||
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
|
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
|
||||||
const url = (el.getAttribute('data-reference-url') || '').trim()
|
const url = (el.getAttribute('data-reference-url') || '').trim()
|
||||||
return `${kind}:${assetId || url}`
|
return `${kind}:${assetUrl || assetId || url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUniqueRefKeysInDocForKind = (kind) => {
|
const getUniqueRefKeysInDocForKind = (kind) => {
|
||||||
|
|
@ -1015,7 +976,12 @@ const renumberAllReferenceMentions = () => {
|
||||||
let droppedExtra = false
|
let droppedExtra = false
|
||||||
for (const el of refs) {
|
for (const el of refs) {
|
||||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||||
const keyCore = (el.getAttribute('data-reference-asset-id') || el.getAttribute('data-reference-url') || '').trim()
|
const keyCore = (
|
||||||
|
(el.getAttribute('data-reference-asset-url') || '').trim() ||
|
||||||
|
el.getAttribute('data-reference-asset-id') ||
|
||||||
|
el.getAttribute('data-reference-url') ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
if (!keyCore) {
|
if (!keyCore) {
|
||||||
el.remove()
|
el.remove()
|
||||||
continue
|
continue
|
||||||
|
|
@ -1063,8 +1029,8 @@ const reconcileReferenceMentions = (nextList) => {
|
||||||
if (!editorRef.value) return
|
if (!editorRef.value) return
|
||||||
const allowed = new Set(
|
const allowed = new Set(
|
||||||
(nextList || [])
|
(nextList || [])
|
||||||
.filter((x) => ['image', 'video', 'audio'].includes(x?.mediaType) && (x?.assetId || x?.url))
|
.filter((x) => ['image', 'video', 'audio'].includes(x?.mediaType) && (x?.assetUrl || x?.assetId || x?.url))
|
||||||
.map((x) => `${x.mediaType}:${x.assetId || x.url}`)
|
.map((x) => `${x.mediaType}:${x.assetUrl || x.assetId || x.url}`)
|
||||||
)
|
)
|
||||||
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
||||||
const k = refKeyForEl(el)
|
const k = refKeyForEl(el)
|
||||||
|
|
@ -1086,12 +1052,13 @@ const collectReferenceContentInOrder = () => {
|
||||||
const el = node
|
const el = node
|
||||||
if (el.dataset?.mentionReference === '1') {
|
if (el.dataset?.mentionReference === '1') {
|
||||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||||
|
const assetUrl = (el.getAttribute('data-reference-asset-url') || '').trim()
|
||||||
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
|
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
|
||||||
const url = (el.getAttribute('data-reference-url') || '').trim()
|
const url = (el.getAttribute('data-reference-url') || '').trim()
|
||||||
const key = `${kind}:${assetId || url}`
|
const key = `${kind}:${assetUrl || assetId || url}`
|
||||||
if (!key || key === `${kind}:` || seen.has(key)) return
|
if (!key || key === `${kind}:` || seen.has(key)) return
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
out.push({ kind, assetId, url })
|
out.push({ kind, assetId, url, assetUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Array.from(el.childNodes).forEach(walk)
|
Array.from(el.childNodes).forEach(walk)
|
||||||
|
|
@ -1109,7 +1076,12 @@ const getImageReferenceContentItems = () => {
|
||||||
const first = { type: 'text', text: text || '' }
|
const first = { type: 'text', text: text || '' }
|
||||||
const refs = collectReferenceContentInOrder()
|
const refs = collectReferenceContentInOrder()
|
||||||
const rest = refs.map((ref) => {
|
const rest = refs.map((ref) => {
|
||||||
const urlStr = ref.assetId ? `asset://${ref.assetId}` : ref.url
|
const urlStr =
|
||||||
|
ref.assetUrl && String(ref.assetUrl).trim()
|
||||||
|
? String(ref.assetUrl).trim()
|
||||||
|
: ref.assetId
|
||||||
|
? `asset://${ref.assetId}`
|
||||||
|
: ref.url
|
||||||
if (ref.kind === 'video') {
|
if (ref.kind === 'video') {
|
||||||
return { type: 'video_url', video_url: { url: urlStr }, role: 'reference_video' }
|
return { type: 'video_url', video_url: { url: urlStr }, role: 'reference_video' }
|
||||||
}
|
}
|
||||||
|
|
@ -1319,18 +1291,20 @@ const onEditorKeyup = (e) => {
|
||||||
|
|
||||||
const buildReferenceHolderElement = (item) => {
|
const buildReferenceHolderElement = (item) => {
|
||||||
const kind = item.mediaType || 'image'
|
const kind = item.mediaType || 'image'
|
||||||
|
const displayUrl = String(item.url || item.assetUrl || '').trim()
|
||||||
const holder = document.createElement('span')
|
const holder = document.createElement('span')
|
||||||
holder.className = 'vg-inline-ref'
|
holder.className = 'vg-inline-ref'
|
||||||
holder.setAttribute('data-mention-reference', '1')
|
holder.setAttribute('data-mention-reference', '1')
|
||||||
holder.setAttribute('data-token', '[?]')
|
holder.setAttribute('data-token', '[?]')
|
||||||
holder.setAttribute('data-reference-url', String(item.url || '').trim())
|
holder.setAttribute('data-reference-url', displayUrl)
|
||||||
holder.setAttribute('data-reference-asset-id', item.assetId || '')
|
holder.setAttribute('data-reference-asset-id', item.assetId || '')
|
||||||
|
holder.setAttribute('data-reference-asset-url', String(item.assetUrl || '').trim())
|
||||||
holder.setAttribute('data-reference-kind', kind)
|
holder.setAttribute('data-reference-kind', kind)
|
||||||
holder.setAttribute('contenteditable', 'false')
|
holder.setAttribute('contenteditable', 'false')
|
||||||
|
|
||||||
if (kind === 'video') {
|
if (kind === 'video') {
|
||||||
const v = document.createElement('video')
|
const v = document.createElement('video')
|
||||||
v.src = item.url
|
v.src = displayUrl
|
||||||
v.className = 'vg-inline-ref-video'
|
v.className = 'vg-inline-ref-video'
|
||||||
v.muted = true
|
v.muted = true
|
||||||
v.playsInline = true
|
v.playsInline = true
|
||||||
|
|
@ -1345,7 +1319,7 @@ const buildReferenceHolderElement = (item) => {
|
||||||
holder.appendChild(span)
|
holder.appendChild(span)
|
||||||
} else {
|
} else {
|
||||||
const img = document.createElement('img')
|
const img = document.createElement('img')
|
||||||
img.src = item.url
|
img.src = displayUrl
|
||||||
img.alt = ''
|
img.alt = ''
|
||||||
img.setAttribute('draggable', 'false')
|
img.setAttribute('draggable', 'false')
|
||||||
img.setAttribute('contenteditable', 'false')
|
img.setAttribute('contenteditable', 'false')
|
||||||
|
|
@ -1382,8 +1356,10 @@ const parseContentItemToMedia = (it, idx) => {
|
||||||
if (it?.type === 'image_url' && (!it.role || it.role === 'reference_image')) {
|
if (it?.type === 'image_url' && (!it.role || it.role === 'reference_image')) {
|
||||||
const raw = String(it.image_url?.url || '').trim()
|
const raw = String(it.image_url?.url || '').trim()
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const assetId = raw.startsWith('asset://') ? raw.slice(9) : ''
|
if (/^asset:\/\//i.test(raw)) {
|
||||||
return { id, url: raw, assetId, mediaType: 'image', name: '' }
|
return { id, url: raw, assetUrl: raw, assetId: '', mediaType: 'image', name: '' }
|
||||||
|
}
|
||||||
|
return { id, url: raw, assetId: '', assetUrl: '', mediaType: 'image', name: '' }
|
||||||
}
|
}
|
||||||
if (it?.type === 'video_url' && it.role === 'reference_video') {
|
if (it?.type === 'video_url' && it.role === 'reference_video') {
|
||||||
const raw = String(it.video_url?.url || '').trim()
|
const raw = String(it.video_url?.url || '').trim()
|
||||||
|
|
@ -1409,8 +1385,9 @@ const findMediaItemForRefUrl = (items, url, kind) => {
|
||||||
items.find((x) => {
|
items.find((x) => {
|
||||||
if ((x.mediaType || 'image') !== kind) return false
|
if ((x.mediaType || 'image') !== kind) return false
|
||||||
const xu = normRefKey(x.url)
|
const xu = normRefKey(x.url)
|
||||||
|
const xau = normRefKey(x.assetUrl)
|
||||||
const xa = normRefKey(x.assetId)
|
const xa = normRefKey(x.assetId)
|
||||||
if (u === xu) return true
|
if (u === xu || u === xau) return true
|
||||||
if (u.startsWith('asset://') && u.slice(9) === xa) return true
|
if (u.startsWith('asset://') && u.slice(9) === xa) return true
|
||||||
if (xa && `asset://${xa}` === u) return true
|
if (xa && `asset://${xa}` === u) return true
|
||||||
return false
|
return false
|
||||||
|
|
@ -1482,9 +1459,9 @@ const loadReferenceFromTaskRow = async (row) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectMentionItem = (item) => {
|
const selectMentionItem = (item) => {
|
||||||
if (!item?.url || !editorRef.value) return
|
if ((!item?.url && !item?.assetUrl) || !editorRef.value) return
|
||||||
const kind = item.mediaType || 'image'
|
const kind = item.mediaType || 'image'
|
||||||
const key = `${kind}:${(item.assetId || '').trim() || String(item.url || '').trim()}`
|
const key = `${kind}:${(item.assetUrl || '').trim() || (item.assetId || '').trim() || String(item.url || '').trim()}`
|
||||||
const keys = getUniqueRefKeysInDocForKind(kind)
|
const keys = getUniqueRefKeysInDocForKind(kind)
|
||||||
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
|
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
|
||||||
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'
|
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ export default {
|
||||||
help: 'مركز المساعدة',
|
help: 'مركز المساعدة',
|
||||||
moneyInvite: 'دعوة مكافآت',
|
moneyInvite: 'دعوة مكافآت',
|
||||||
assetGroupManage: 'إدارة مجموعات الأصول',
|
assetGroupManage: 'إدارة مجموعات الأصول',
|
||||||
assetManage: 'إدارة الأصول'
|
assetManage: 'إدارة الأصول',
|
||||||
|
thirdPartyAsset: 'أصول الطرف الثالث'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ export default {
|
||||||
help: 'Help Center',
|
help: 'Help Center',
|
||||||
moneyInvite: 'Reward Invitation',
|
moneyInvite: 'Reward Invitation',
|
||||||
assetGroupManage: 'Asset Group Manage',
|
assetGroupManage: 'Asset Group Manage',
|
||||||
assetManage: 'Asset Manage'
|
assetManage: 'Asset Manage',
|
||||||
|
thirdPartyAsset: 'Third-party assets'
|
||||||
}
|
}
|
||||||
|
|
@ -10,5 +10,6 @@ export default {
|
||||||
help: 'Centro de ayuda',
|
help: 'Centro de ayuda',
|
||||||
moneyInvite: 'Invitación con recompensa',
|
moneyInvite: 'Invitación con recompensa',
|
||||||
assetGroupManage: 'Gestión de grupos de activos',
|
assetGroupManage: 'Gestión de grupos de activos',
|
||||||
assetManage: 'Gestión de activos'
|
assetManage: 'Gestión de activos',
|
||||||
|
thirdPartyAsset: 'Activos de terceros'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ export default {
|
||||||
help: 'Centre d\'aide',
|
help: 'Centre d\'aide',
|
||||||
moneyInvite: 'Invitation avec récompense',
|
moneyInvite: 'Invitation avec récompense',
|
||||||
assetGroupManage: 'Gestion des groupes d\'actifs',
|
assetGroupManage: 'Gestion des groupes d\'actifs',
|
||||||
assetManage: 'Gestion des actifs'
|
assetManage: 'Gestion des actifs',
|
||||||
|
thirdPartyAsset: 'Actifs tiers'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ export default {
|
||||||
help: 'सहायता केंद्र',
|
help: 'सहायता केंद्र',
|
||||||
moneyInvite: 'इनाम निमंत्रण',
|
moneyInvite: 'इनाम निमंत्रण',
|
||||||
assetGroupManage: 'एसेट समूह प्रबंधन',
|
assetGroupManage: 'एसेट समूह प्रबंधन',
|
||||||
assetManage: 'एसेट प्रबंधन'
|
assetManage: 'एसेट प्रबंधन',
|
||||||
|
thirdPartyAsset: 'तीसरे पक्ष की सामग्री'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ export default {
|
||||||
help: 'Central de ajuda',
|
help: 'Central de ajuda',
|
||||||
moneyInvite: 'Convite com recompensa',
|
moneyInvite: 'Convite com recompensa',
|
||||||
assetGroupManage: 'Gerenciamento de grupos de ativos',
|
assetGroupManage: 'Gerenciamento de grupos de ativos',
|
||||||
assetManage: 'Gerenciamento de ativos'
|
assetManage: 'Gerenciamento de ativos',
|
||||||
|
thirdPartyAsset: 'Ativos de terceiros'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ export default {
|
||||||
help: 'Центр помощи',
|
help: 'Центр помощи',
|
||||||
moneyInvite: 'Приглашение с наградой',
|
moneyInvite: 'Приглашение с наградой',
|
||||||
assetGroupManage: 'Управление группами ресурсов',
|
assetGroupManage: 'Управление группами ресурсов',
|
||||||
assetManage: 'Управление ресурсами'
|
assetManage: 'Управление ресурсами',
|
||||||
|
thirdPartyAsset: 'Сторонние материалы'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ export default {
|
||||||
help: '幫助中心',
|
help: '幫助中心',
|
||||||
moneyInvite: '有獎邀請',
|
moneyInvite: '有獎邀請',
|
||||||
assetGroupManage: '資源組管理',
|
assetGroupManage: '資源組管理',
|
||||||
assetManage: '素材管理'
|
assetManage: '素材管理',
|
||||||
|
thirdPartyAsset: '三方素材管理'
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +47,7 @@ import { constantRoutes } from '@/router/index'
|
||||||
import { generateTitle, generateLang } from '@/utils/i18n'
|
import { generateTitle, generateLang } from '@/utils/i18n'
|
||||||
|
|
||||||
/** 左侧导航仅显示这些路由(name 与 router/index.js 一致) */
|
/** 左侧导航仅显示这些路由(name 与 router/index.js 一致) */
|
||||||
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'asset-group-manage', 'asset-manage']
|
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'third-party-asset']
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
collapsed: Boolean
|
collapsed: Boolean
|
||||||
|
|
@ -149,11 +149,8 @@ const getMenuSvg = (key) => {
|
||||||
if (key === 'video-gen') {
|
if (key === 'video-gen') {
|
||||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18Zm-1.5 5.2a.9.9 0 0 1 1.35-.78l4.7 2.7a.9.9 0 0 1 0 1.56l-4.7 2.7a.9.9 0 0 1-1.35-.78V8.2Z"/></svg>'
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18Zm-1.5 5.2a.9.9 0 0 1 1.35-.78l4.7 2.7a.9.9 0 0 1 0 1.56l-4.7 2.7a.9.9 0 0 1-1.35-.78V8.2Z"/></svg>'
|
||||||
}
|
}
|
||||||
if (key === 'asset-group-manage') {
|
if (key === 'third-party-asset') {
|
||||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l1.7 2H18.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-10Z"/></svg>'
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h7l2 2h7v12H4V6Zm2 4v8h12v-8H6Zm2 2h8v4H8v-4Z"/></svg>'
|
||||||
}
|
|
||||||
if (key === 'asset-manage') {
|
|
||||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm1.8 11.5h10.4L14 11.4l-2.6 3.1-1.7-2.1-2.9 3.1Zm2.7-6.1a1.6 1.6 0 1 0 0-3.2a1.6 1.6 0 0 0 0 3.2Z"/></svg>'
|
|
||||||
}
|
}
|
||||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>'
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,16 @@ export const constantRoutes = [{
|
||||||
permission: "pass",
|
permission: "pass",
|
||||||
icon: 'btn_video'
|
icon: 'btn_video'
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
path: 'third-party-asset',
|
||||||
|
name: 'third-party-asset',
|
||||||
|
component: () => import('@/views/ThirdPartyAsset.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'thirdPartyAsset',
|
||||||
|
menuItem: true,
|
||||||
|
permission: "pass",
|
||||||
|
icon: 'btn_video'
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'asset-group-manage',
|
path: 'asset-group-manage',
|
||||||
name: 'asset-group-manage',
|
name: 'asset-group-manage',
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,10 @@ const logout = () => {
|
||||||
*/
|
*/
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
if (config.url && config.url.includes('file/upload')) {
|
if (
|
||||||
|
config.url &&
|
||||||
|
(config.url.includes('file/upload') || config.url.includes('cos/upload'))
|
||||||
|
) {
|
||||||
config.headers['Content-Type'] =
|
config.headers['Content-Type'] =
|
||||||
'multipart/form-data;boundary = ' + new Date().getTime()
|
'multipart/form-data;boundary = ' + new Date().getTime()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,439 @@
|
||||||
|
<template>
|
||||||
|
<div class="tpa-page">
|
||||||
|
<section class="tpa-panel">
|
||||||
|
<div class="tpa-head">
|
||||||
|
<h3 class="tpa-title">三方素材管理</h3>
|
||||||
|
<a-button type="primary" @click="openCreateDialog">新增素材</a-button>
|
||||||
|
</div>
|
||||||
|
<p class="tpa-desc">
|
||||||
|
提交审核通过后,素材会入库并展示在下方列表,可继续查询或删除。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tpa-filter">
|
||||||
|
<a-input v-model="filters.reviewBatchId" allow-clear placeholder="审核批次 ID" style="width: 220px" />
|
||||||
|
<a-button type="primary" :loading="listLoading" @click="loadList(1)">查询</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin :loading="listLoading">
|
||||||
|
<div class="tpa-total">共 {{ total }} 条</div>
|
||||||
|
<div class="tpa-table-wrap">
|
||||||
|
<table class="tpa-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>批次</th>
|
||||||
|
<th>资产ID</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>审核状态</th>
|
||||||
|
<th>来源 URL</th>
|
||||||
|
<th>TOS URL</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td>{{ row.id }}</td>
|
||||||
|
<td class="tpa-ellipsis" :title="row.reviewBatchId">{{ row.reviewBatchId }}</td>
|
||||||
|
<td class="tpa-ellipsis" :title="row.assetId">{{ row.assetId || '-' }}</td>
|
||||||
|
<td>{{ formatAssetType(row.assetType) }}</td>
|
||||||
|
<td>{{ formatReviewStatus(row.submitReviewStatus) }}</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="row.sourceUrl" class="tpa-source-preview">
|
||||||
|
<img v-if="isImageType(row.assetType)" :src="row.sourceUrl" alt="来源图片" />
|
||||||
|
<video v-else-if="isVideoType(row.assetType)" :src="row.sourceUrl" controls preload="metadata"></video>
|
||||||
|
<audio v-else-if="isAudioType(row.assetType)" :src="row.sourceUrl" controls preload="none"></audio>
|
||||||
|
<a v-else :href="row.sourceUrl" target="_blank" rel="noopener">打开</a>
|
||||||
|
</div>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td class="tpa-ellipsis">
|
||||||
|
<a v-if="row.tosUrl" :href="row.tosUrl" target="_blank" rel="noopener">打开</a>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a-button type="outline" status="danger" size="mini" :loading="deletingId === row.id" @click="removeRow(row)">
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!rows.length">
|
||||||
|
<td colspan="8" class="tpa-empty">暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<a-pagination
|
||||||
|
class="tpa-pagination"
|
||||||
|
:total="total"
|
||||||
|
:current="pageNum"
|
||||||
|
:page-size="pageSize"
|
||||||
|
show-total
|
||||||
|
@change="onPageChange" />
|
||||||
|
</a-spin>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a-modal v-model:visible="createDialogVisible" title="新增素材" :footer="false" :mask-closable="false" width="680px">
|
||||||
|
<div class="tpa-upload-block">
|
||||||
|
<div class="tpa-type-select">
|
||||||
|
<span class="tpa-type-label">类型选择:</span>
|
||||||
|
<a-radio-group v-model="createMediaType" type="button" @change="onCreateTypeChange">
|
||||||
|
<a-radio value="image">图片</a-radio>
|
||||||
|
<a-radio value="video">视频</a-radio>
|
||||||
|
<a-radio value="audio">音频</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
<div class="tpa-upload-label">添加素材,拖拽或者点击上传</div>
|
||||||
|
<a-upload
|
||||||
|
:key="uploaderKey"
|
||||||
|
draggable
|
||||||
|
multiple
|
||||||
|
:accept="uploadAcceptByType(createMediaType)"
|
||||||
|
:custom-request="customRequest"
|
||||||
|
:show-file-list="true"
|
||||||
|
@change="onUploadChange" />
|
||||||
|
<div class="tpa-upload-actions">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="!pendingUrls.length"
|
||||||
|
@click="submitModeration">
|
||||||
|
提交审核
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="cancelCreateDialog">取消</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ThirdPartyAsset',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
createDialogVisible: false,
|
||||||
|
createMediaType: 'image',
|
||||||
|
uploaderKey: 0,
|
||||||
|
pendingUrls: [],
|
||||||
|
submitLoading: false,
|
||||||
|
listLoading: false,
|
||||||
|
rows: [],
|
||||||
|
total: 0,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
filters: {
|
||||||
|
reviewBatchId: ''
|
||||||
|
},
|
||||||
|
deletingId: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadList(1)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isImageType(assetType) {
|
||||||
|
return String(assetType || '').toLowerCase().includes('image')
|
||||||
|
},
|
||||||
|
isAudioType(assetType) {
|
||||||
|
return String(assetType || '').toLowerCase().includes('audio')
|
||||||
|
},
|
||||||
|
isVideoType(assetType) {
|
||||||
|
return String(assetType || '').toLowerCase().includes('video')
|
||||||
|
},
|
||||||
|
formatAssetType(assetType) {
|
||||||
|
if (this.isImageType(assetType)) return '图片'
|
||||||
|
if (this.isAudioType(assetType)) return '音频'
|
||||||
|
if (this.isVideoType(assetType)) return '视频'
|
||||||
|
return '-'
|
||||||
|
},
|
||||||
|
formatReviewStatus(status) {
|
||||||
|
const code = Number(status)
|
||||||
|
if (code === 1) return '审核通过'
|
||||||
|
if (code === 0) return '待审核'
|
||||||
|
return status != null ? String(status) : '-'
|
||||||
|
},
|
||||||
|
openCreateDialog() {
|
||||||
|
this.createDialogVisible = true
|
||||||
|
},
|
||||||
|
onCreateTypeChange() {
|
||||||
|
this.clearPendingUploads()
|
||||||
|
},
|
||||||
|
uploadAcceptByType(mediaType) {
|
||||||
|
if (mediaType === 'audio') return 'audio/*'
|
||||||
|
if (mediaType === 'video') return 'video/*'
|
||||||
|
return 'image/*'
|
||||||
|
},
|
||||||
|
mediaDataKeyByType(mediaType) {
|
||||||
|
if (mediaType === 'audio') return 'audios'
|
||||||
|
if (mediaType === 'video') return 'videos'
|
||||||
|
return 'images'
|
||||||
|
},
|
||||||
|
customRequest(option) {
|
||||||
|
const { onProgress, onError, onSuccess, fileItem } = option
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', fileItem.file)
|
||||||
|
form.append('pathPrefix', 'asset')
|
||||||
|
this.$axios({
|
||||||
|
url: 'api/cos/upload',
|
||||||
|
method: 'post',
|
||||||
|
data: form,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data;boundary=' + new Date().getTime()
|
||||||
|
},
|
||||||
|
onUploadProgress: (evt) => {
|
||||||
|
if (evt.total) onProgress(evt.loaded / evt.total, evt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.code === 200 && res.url) {
|
||||||
|
this.pendingUrls.push(res.url)
|
||||||
|
onSuccess(res)
|
||||||
|
} else {
|
||||||
|
const msg = (res && res.msg) || '上传失败'
|
||||||
|
Message.error(msg)
|
||||||
|
onError(new Error(msg))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
onError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUploadChange(_list, fileItem) {
|
||||||
|
const st = fileItem && fileItem.status
|
||||||
|
if (st === 'removed' && fileItem.response && fileItem.response.url) {
|
||||||
|
const u = fileItem.response.url
|
||||||
|
const idx = this.pendingUrls.findIndex((x) => x === u)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.pendingUrls.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearPendingUploads() {
|
||||||
|
this.pendingUrls = []
|
||||||
|
this.uploaderKey += 1
|
||||||
|
},
|
||||||
|
cancelCreateDialog() {
|
||||||
|
this.clearPendingUploads()
|
||||||
|
this.createDialogVisible = false
|
||||||
|
},
|
||||||
|
async submitModeration() {
|
||||||
|
if (!this.pendingUrls.length) {
|
||||||
|
Message.warning('请先上传素材')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
[this.mediaDataKeyByType(this.createMediaType)]: [...this.pendingUrls]
|
||||||
|
}
|
||||||
|
this.submitLoading = true
|
||||||
|
try {
|
||||||
|
const res = await this.$axios({
|
||||||
|
url: 'api/tos/asset',
|
||||||
|
method: 'post',
|
||||||
|
data: payload
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
Message.success('已提交审核')
|
||||||
|
this.clearPendingUploads()
|
||||||
|
this.createDialogVisible = false
|
||||||
|
this.loadList(1)
|
||||||
|
} else {
|
||||||
|
Message.error(res.msg || '提交失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Message.error(typeof e === 'string' ? e : e?.message || '提交失败')
|
||||||
|
} finally {
|
||||||
|
this.submitLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadList(page) {
|
||||||
|
this.pageNum = page || this.pageNum
|
||||||
|
this.listLoading = true
|
||||||
|
try {
|
||||||
|
const q = {
|
||||||
|
pageNum: this.pageNum,
|
||||||
|
pageSize: this.pageSize
|
||||||
|
}
|
||||||
|
if (this.filters.reviewBatchId) {
|
||||||
|
q.reviewBatchId = this.filters.reviewBatchId
|
||||||
|
}
|
||||||
|
const res = await this.$axios({
|
||||||
|
url: 'api/tos/asset/list',
|
||||||
|
method: 'get',
|
||||||
|
data: q
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
this.rows = res.rows || []
|
||||||
|
this.total = res.total || 0
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.listLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageChange(p) {
|
||||||
|
this.pageNum = p
|
||||||
|
this.loadList(p)
|
||||||
|
},
|
||||||
|
async removeRow(row) {
|
||||||
|
if (!row || !row.id) return
|
||||||
|
this.deletingId = row.id
|
||||||
|
try {
|
||||||
|
const res = await this.$axios({
|
||||||
|
url: `api/tos/asset/${row.id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
Message.success('已删除')
|
||||||
|
this.loadList(this.pageNum)
|
||||||
|
} else {
|
||||||
|
Message.error(res.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Message.error(typeof e === 'string' ? e : e?.message || '删除失败')
|
||||||
|
} finally {
|
||||||
|
this.deletingId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tpa-page {
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tpa-panel {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.tpa-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tpa-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tpa-desc {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
line-height: 1.5;
|
||||||
|
code {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tpa-upload-block {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.tpa-upload-label {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.tpa-type-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.tpa-type-label {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tpa-upload-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tpa-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tpa-total {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tpa-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 360px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.tpa-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #6ad0ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tpa-source-preview {
|
||||||
|
width: 140px;
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
width: 140px;
|
||||||
|
max-height: 86px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
audio {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tpa-ellipsis {
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tpa-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
padding: 24px !important;
|
||||||
|
}
|
||||||
|
.tpa-pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1132,8 +1132,9 @@ export default {
|
||||||
// 展示用仍然可以是 https url,但提交 content/reference_url 要与 assetId 关联。
|
// 展示用仍然可以是 https url,但提交 content/reference_url 要与 assetId 关联。
|
||||||
const firstPreview = attachments.find((x) => x?.mediaType === 'image')
|
const firstPreview = attachments.find((x) => x?.mediaType === 'image')
|
||||||
if (firstPreview) {
|
if (firstPreview) {
|
||||||
|
const au = String(firstPreview?.assetUrl || '').trim()
|
||||||
const aid = String(firstPreview?.assetId || '').trim()
|
const aid = String(firstPreview?.assetId || '').trim()
|
||||||
params.referenceUrl = aid ? `asset://${aid}` : firstPreview.url
|
params.referenceUrl = au || (aid ? `asset://${aid}` : firstPreview.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.ruoyi.api;
|
package com.ruoyi.api;
|
||||||
|
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
import com.ruoyi.common.utils.TencentCosUtil;
|
import com.ruoyi.common.utils.TencentCosUtil;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
|
@ -28,8 +29,11 @@ public class CosController {
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
public AjaxResult upload(
|
public AjaxResult upload(
|
||||||
@ApiParam(name = "file", value = "文件", required = true)
|
@ApiParam(name = "file", value = "文件", required = true)
|
||||||
@RequestParam("file") MultipartFile file) throws Exception {
|
@RequestParam("file") MultipartFile file,
|
||||||
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true);
|
@ApiParam(name = "pathPrefix", value = "对象键前缀,如 asset,则路径为 asset/yyyy/MM/dd/...")
|
||||||
|
@RequestParam(value = "pathPrefix", required = false) String pathPrefix) throws Exception {
|
||||||
|
String prefix = StringUtils.isNotEmpty(pathPrefix) ? pathPrefix.trim() : null;
|
||||||
|
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true, prefix);
|
||||||
AjaxResult ajax = AjaxResult.success(uploadUrl);
|
AjaxResult ajax = AjaxResult.success(uploadUrl);
|
||||||
ajax.put("url", uploadUrl);
|
ajax.put("url", uploadUrl);
|
||||||
ajax.put("oldName", file.getOriginalFilename());
|
ajax.put("oldName", file.getOriginalFilename());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.ruoyi.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.ruoyi.ai.domain.TosAsset;
|
||||||
|
import com.ruoyi.ai.domain.dto.TosAssetSubmitRequest;
|
||||||
|
import com.ruoyi.ai.service.IModerationImageService;
|
||||||
|
import com.ruoyi.ai.service.ITosAssetService;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.core.page.TableDataInfo;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
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.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOS 图片审核:加密对接第三方 /api/moderation/image,解密结果落库 tos_asset。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Api(tags = "TOS-审核资产")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/tos")
|
||||||
|
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||||
|
public class TosAssetController extends BaseController {
|
||||||
|
|
||||||
|
private final IModerationImageService moderationImageService;
|
||||||
|
private final ITosAssetService tosAssetService;
|
||||||
|
|
||||||
|
@GetMapping("/asset/list")
|
||||||
|
@ApiOperation("分页查询当前用户的 tos_asset")
|
||||||
|
public TableDataInfo list(TosAsset query) {
|
||||||
|
startPage();
|
||||||
|
return getDataTable(tosAssetService.selectTosAssetList(query, SecurityUtils.getAiUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/asset/{id}")
|
||||||
|
@ApiOperation("删除单条 tos_asset(仅本人数据)")
|
||||||
|
public AjaxResult<Void> remove(@PathVariable Long id) {
|
||||||
|
try {
|
||||||
|
tosAssetService.deleteByIdForUser(id, SecurityUtils.getAiUserId());
|
||||||
|
return AjaxResult.success();
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
return AjaxResult.error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/asset")
|
||||||
|
@ApiOperation("提交图片审核(加密转发 + 解密落库)")
|
||||||
|
public AjaxResult<JsonNode> submitAsset(@RequestBody TosAssetSubmitRequest request) {
|
||||||
|
try {
|
||||||
|
JsonNode data = moderationImageService.submitImageModeration(request, SecurityUtils.getAiUserId());
|
||||||
|
return AjaxResult.success(data);
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
return AjaxResult.error(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("TOS 图片审核失败", e);
|
||||||
|
return AjaxResult.error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -247,6 +247,14 @@ volcengine:
|
||||||
callBackUrl: http://47.86.170.114:5173/
|
callBackUrl: http://47.86.170.114:5173/
|
||||||
|
|
||||||
# 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举
|
# 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举
|
||||||
|
# 第三方图片素材审核(AES-256-ECB + Base64,与对端文档一致)
|
||||||
|
moderation:
|
||||||
|
image:
|
||||||
|
url: http://118.196.112.236:3428/api/moderation/image
|
||||||
|
user_id: 72
|
||||||
|
encryption_key_id: 3de57cb256df4bd7bb52297eaf363e81
|
||||||
|
aesHexKey: 7152a23b1a2a8759726a82e14c44c1e19d65b223f2469fe518577ce73c98245c
|
||||||
|
|
||||||
portal:
|
portal:
|
||||||
video:
|
video:
|
||||||
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist,请插入对应 type 或改此处与库一致
|
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist,请插入对应 type 或改此处与库一致
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,13 @@ public class TencentCosUtil {
|
||||||
* @return 文件访问地址(URL字符串)
|
* @return 文件访问地址(URL字符串)
|
||||||
*/
|
*/
|
||||||
public String uploadMultipartFile(MultipartFile file, boolean isPublic) throws Exception {
|
public String uploadMultipartFile(MultipartFile file, boolean isPublic) throws Exception {
|
||||||
|
return uploadMultipartFile(file, isPublic, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param objectKeyPrefix 对象键前缀,如 {@code asset},最终 key 为 {@code asset/yyyy/MM/dd/uuid_name};{@code null} 或空则不添加
|
||||||
|
*/
|
||||||
|
public String uploadMultipartFile(MultipartFile file, boolean isPublic, String objectKeyPrefix) throws Exception {
|
||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
throw new IllegalArgumentException("上传文件不能为空");
|
throw new IllegalArgumentException("上传文件不能为空");
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +70,10 @@ public class TencentCosUtil {
|
||||||
|
|
||||||
// 生成唯一文件键,格式与AWS一致:yyyy/MM/dd/uuid_filename
|
// 生成唯一文件键,格式与AWS一致:yyyy/MM/dd/uuid_filename
|
||||||
String key = generateCosKey(file.getOriginalFilename());
|
String key = generateCosKey(file.getOriginalFilename());
|
||||||
|
if (objectKeyPrefix != null && !objectKeyPrefix.trim().isEmpty()) {
|
||||||
|
String p = objectKeyPrefix.trim().replaceAll("^/+", "").replaceAll("/+$", "");
|
||||||
|
key = p + "/" + key;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
InputStream inputStream = file.getInputStream();
|
InputStream inputStream = file.getInputStream();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.ruoyi.common.utils.crypto;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-ECB + PKCS5Padding(与 PKCS7 在 16 字节块上等价),密钥为 32 字节。
|
||||||
|
* 密文传输形态:Base64 字符串。
|
||||||
|
*/
|
||||||
|
public final class Aes256EcbPkcs5 {
|
||||||
|
|
||||||
|
private static final int AES_KEY_BYTES = 32;
|
||||||
|
private static final String AES_ECB_PKCS5 = "AES/ECB/PKCS5Padding";
|
||||||
|
|
||||||
|
private Aes256EcbPkcs5() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param hex 64 位十六进制字符串(32 字节密钥)
|
||||||
|
*/
|
||||||
|
public static byte[] hexStringToBytes(String hex) {
|
||||||
|
if (hex == null || hex.length() != AES_KEY_BYTES * 2) {
|
||||||
|
throw new IllegalArgumentException("AES-256 hex key must be 64 hex characters");
|
||||||
|
}
|
||||||
|
int n = hex.length() / 2;
|
||||||
|
byte[] out = new byte[n];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
int hi = Character.digit(hex.charAt(i * 2), 16);
|
||||||
|
int lo = Character.digit(hex.charAt(i * 2 + 1), 16);
|
||||||
|
if (hi < 0 || lo < 0) {
|
||||||
|
throw new IllegalArgumentException("invalid hex in AES key");
|
||||||
|
}
|
||||||
|
out[i] = (byte) ((hi << 4) + lo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String encryptToBase64(String plainUtf8, byte[] key256) throws GeneralSecurityException {
|
||||||
|
if (plainUtf8 == null) {
|
||||||
|
throw new IllegalArgumentException("plain text must not be null");
|
||||||
|
}
|
||||||
|
if (key256 == null || key256.length != AES_KEY_BYTES) {
|
||||||
|
throw new IllegalArgumentException("AES key must be 32 bytes");
|
||||||
|
}
|
||||||
|
Cipher cipher = Cipher.getInstance(AES_ECB_PKCS5);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key256, "AES"));
|
||||||
|
byte[] cipherBytes = cipher.doFinal(plainUtf8.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(cipherBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String decryptFromBase64(String base64Cipher, byte[] key256) throws GeneralSecurityException {
|
||||||
|
if (base64Cipher == null || base64Cipher.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("empty ciphertext");
|
||||||
|
}
|
||||||
|
if (key256 == null || key256.length != AES_KEY_BYTES) {
|
||||||
|
throw new IllegalArgumentException("AES key must be 32 bytes");
|
||||||
|
}
|
||||||
|
byte[] raw = Base64.getDecoder().decode(base64Cipher.trim());
|
||||||
|
Cipher cipher = Cipher.getInstance(AES_ECB_PKCS5);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key256, "AES"));
|
||||||
|
byte[] plain = cipher.doFinal(raw);
|
||||||
|
return new String(plain, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.ruoyi.ai.domain;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOS 审核资产条目表 tos_asset
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class TosAsset {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long aiUserId;
|
||||||
|
private String reviewBatchId;
|
||||||
|
private String assetId;
|
||||||
|
private String assetType;
|
||||||
|
private String assetUrl;
|
||||||
|
private String downstreamAssetId;
|
||||||
|
private String downstreamFinalUrl;
|
||||||
|
private String sourceUrl;
|
||||||
|
private Integer submitReviewStatus;
|
||||||
|
private String tosUrl;
|
||||||
|
/** 解密后的业务 JSON 片段备份(单条或整段) */
|
||||||
|
private String resultJson;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.ruoyi.ai.domain.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户提交图片审核:明文入参(服务端加密后转发第三方)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class TosAssetSubmitRequest {
|
||||||
|
|
||||||
|
/** 图片 URL 列表 */
|
||||||
|
private List<String> images;
|
||||||
|
|
||||||
|
/** 音频 URL 列表 */
|
||||||
|
private List<String> audios;
|
||||||
|
|
||||||
|
/** 视频 URL 列表 */
|
||||||
|
private List<String> videos;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.ruoyi.ai.mapper;
|
||||||
|
|
||||||
|
import com.ruoyi.ai.domain.TosAsset;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOS 审核资产
|
||||||
|
*/
|
||||||
|
public interface TosAssetMapper {
|
||||||
|
|
||||||
|
int insertTosAsset(TosAsset row);
|
||||||
|
|
||||||
|
TosAsset selectTosAssetById(Long id);
|
||||||
|
|
||||||
|
List<TosAsset> selectTosAssetList(TosAsset query);
|
||||||
|
|
||||||
|
int deleteTosAssetById(Long id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.ruoyi.ai.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.ruoyi.ai.domain.dto.TosAssetSubmitRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第三方图片审核:AES 加密对接 + 解密结果落库 tos_asset
|
||||||
|
*/
|
||||||
|
public interface IModerationImageService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交图片审核,解密响应后写入 tos_asset,并返回解密后的业务 JSON。
|
||||||
|
*
|
||||||
|
* @param aiUserId 仅用于本库 tos_asset.ai_user_id,不作为审核接口入参
|
||||||
|
*/
|
||||||
|
JsonNode submitImageModeration(TosAssetSubmitRequest request, Long aiUserId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.ruoyi.ai.service;
|
||||||
|
|
||||||
|
import com.ruoyi.ai.domain.TosAsset;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三方审核资产 tos_asset 门户侧查询、删除
|
||||||
|
*/
|
||||||
|
public interface ITosAssetService {
|
||||||
|
|
||||||
|
List<TosAsset> selectTosAssetList(TosAsset query, Long aiUserId);
|
||||||
|
|
||||||
|
int deleteByIdForUser(Long id, Long aiUserId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
package com.ruoyi.ai.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.ruoyi.ai.domain.TosAsset;
|
||||||
|
import com.ruoyi.ai.domain.dto.TosAssetSubmitRequest;
|
||||||
|
import com.ruoyi.ai.mapper.TosAssetMapper;
|
||||||
|
import com.ruoyi.ai.service.IModerationImageService;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import com.ruoyi.common.utils.RandomStringUtil;
|
||||||
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
|
import com.ruoyi.common.utils.crypto.Aes256EcbPkcs5;
|
||||||
|
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import okhttp3.Response;
|
||||||
|
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.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档:图片素材审核 /api/moderation/image,请求体 encrypted_data 为 AES-256-ECB 加密后的业务 JSON。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ModerationImageServiceImpl implements IModerationImageService {
|
||||||
|
|
||||||
|
private static final ObjectMapper OM = new ObjectMapper();
|
||||||
|
private static final MediaType JSON_MEDIA = MediaType.parse("application/json; charset=utf-8");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TosAssetMapper tosAssetMapper;
|
||||||
|
|
||||||
|
@Value("${moderation.image.url:http://118.196.112.236:3428/api/moderation/image}")
|
||||||
|
private String moderationUrl;
|
||||||
|
|
||||||
|
/** 64 位十六进制 AES-256 密钥(your_64_char_hex_key) */
|
||||||
|
@Value("${moderation.image.aesHexKey:}")
|
||||||
|
private String aesHexKey;
|
||||||
|
|
||||||
|
/** 审核接口必填 user_id */
|
||||||
|
@Value("${moderation.image.user_id:}")
|
||||||
|
private String moderationUserId;
|
||||||
|
|
||||||
|
/** 审核接口可选 encryption_key_id(兼容字段) */
|
||||||
|
@Value("${moderation.image.encryption_key_id:}")
|
||||||
|
private String encryptionKeyId;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public JsonNode submitImageModeration(TosAssetSubmitRequest request, Long aiUserId) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new ServiceException("请求体不能为空");
|
||||||
|
}
|
||||||
|
List<String> imageUrls = normalizeMediaUrls(request.getImages());
|
||||||
|
List<String> audioUrls = normalizeMediaUrls(request.getAudios());
|
||||||
|
List<String> videoUrls = normalizeMediaUrls(request.getVideos());
|
||||||
|
if (imageUrls.isEmpty() && audioUrls.isEmpty() && videoUrls.isEmpty()) {
|
||||||
|
throw new ServiceException("images、audios、videos 不能同时为空");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(aesHexKey) || aesHexKey.trim().length() != 64) {
|
||||||
|
throw new ServiceException("未配置审核密钥:请配置 moderation.image.aesHexKey(64 位十六进制 AES-256)");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(moderationUserId)) {
|
||||||
|
throw new ServiceException("未配置审核参数:请配置 moderation.image.user_id");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(moderationUrl)) {
|
||||||
|
throw new ServiceException("未配置审核地址 moderation.image.url");
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] keyBytes;
|
||||||
|
try {
|
||||||
|
keyBytes = Aes256EcbPkcs5.hexStringToBytes(aesHexKey.trim());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ServiceException("moderation.image.aesHexKey 格式无效:" + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
String reviewBatchId = newReviewBatchId();
|
||||||
|
|
||||||
|
ObjectNode biz = OM.createObjectNode();
|
||||||
|
ArrayNode images = biz.putArray("images");
|
||||||
|
for (String img : imageUrls) {
|
||||||
|
images.add(img);
|
||||||
|
}
|
||||||
|
ArrayNode audios = biz.putArray("audios");
|
||||||
|
for (String audio : audioUrls) {
|
||||||
|
audios.add(audio);
|
||||||
|
}
|
||||||
|
ArrayNode videos = biz.putArray("videos");
|
||||||
|
for (String video : videoUrls) {
|
||||||
|
videos.add(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String encryptedPayload;
|
||||||
|
try {
|
||||||
|
String plain = OM.writeValueAsString(biz);
|
||||||
|
encryptedPayload = Aes256EcbPkcs5.encryptToBase64(plain, keyBytes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("审核请求明文序列化或加密失败", e);
|
||||||
|
throw new ServiceException("审核请求加密失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode upstream = OM.createObjectNode();
|
||||||
|
upstream.put("encrypted_data", encryptedPayload);
|
||||||
|
upstream.put("user_id", moderationUserId.trim());
|
||||||
|
if (StringUtils.isNotEmpty(encryptionKeyId)) {
|
||||||
|
upstream.put("encryption_key_id", encryptionKeyId.trim());
|
||||||
|
} else {
|
||||||
|
throw new ServiceException("未配置审核参数:请配置 moderation.image.encryption_key_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String responseBody;
|
||||||
|
try {
|
||||||
|
OkHttpClient client = OkHttpUtils.createOkHttpClient();
|
||||||
|
String bodyJson = OM.writeValueAsString(upstream);
|
||||||
|
RequestBody rb = RequestBody.create(JSON_MEDIA, bodyJson);
|
||||||
|
Request httpReq = new Request.Builder().url(moderationUrl.trim()).post(rb).build();
|
||||||
|
try (Response resp = client.newCall(httpReq).execute()) {
|
||||||
|
if (resp.body() == null) {
|
||||||
|
throw new ServiceException("审核服务无响应体,HTTP " + resp.code());
|
||||||
|
}
|
||||||
|
responseBody = resp.body().string();
|
||||||
|
if (!resp.isSuccessful()) {
|
||||||
|
throw new ServiceException("审核服务 HTTP " + resp.code() + ":" + truncate(responseBody, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("调用审核接口失败", e);
|
||||||
|
throw new ServiceException("调用审核接口失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonNode root;
|
||||||
|
try {
|
||||||
|
root = OM.readTree(responseBody);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ServiceException("审核响应非 JSON:" + truncate(responseBody, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.has("encrypted_data") || StringUtils.isEmpty(root.get("encrypted_data").asText())) {
|
||||||
|
String msg = root.path("message").asText(root.path("msg").asText("审核服务返回错误"));
|
||||||
|
throw new ServiceException(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String decrypted;
|
||||||
|
try {
|
||||||
|
decrypted = Aes256EcbPkcs5.decryptFromBase64(root.get("encrypted_data").asText(), keyBytes);
|
||||||
|
} catch (IllegalArgumentException | GeneralSecurityException e) {
|
||||||
|
log.warn("审核响应解密失败", e);
|
||||||
|
throw new ServiceException("审核响应解密失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode business;
|
||||||
|
try {
|
||||||
|
business = OM.readTree(decrypted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ServiceException("解密后内容非 JSON:" + truncate(decrypted, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
persistDecrypted(decrypted, reviewBatchId, aiUserId, firstSourceUrl(imageUrls, audioUrls, videoUrls));
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> normalizeMediaUrls(List<String> input) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (input == null || input.isEmpty()) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (String u : input) {
|
||||||
|
if (StringUtils.isNotEmpty(u)) {
|
||||||
|
out.add(u.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstSourceUrl(List<String> imageUrls, List<String> audioUrls, List<String> videoUrls) {
|
||||||
|
if (imageUrls != null && !imageUrls.isEmpty()) {
|
||||||
|
return imageUrls.get(0);
|
||||||
|
}
|
||||||
|
if (audioUrls != null && !audioUrls.isEmpty()) {
|
||||||
|
return audioUrls.get(0);
|
||||||
|
}
|
||||||
|
if (videoUrls != null && !videoUrls.isEmpty()) {
|
||||||
|
return videoUrls.get(0);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistDecrypted(String decryptedJson, String defaultBatchId, Long aiUserId, String requestSourceUrl) {
|
||||||
|
try {
|
||||||
|
JsonNode root = OM.readTree(decryptedJson);
|
||||||
|
String batchId = firstText(root, "review_batch_id", "reviewBatchId");
|
||||||
|
if (StringUtils.isEmpty(batchId)) {
|
||||||
|
batchId = defaultBatchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.has("items") && root.get("items").isArray() && root.get("items").size() > 0) {
|
||||||
|
for (JsonNode item : root.get("items")) {
|
||||||
|
TosAsset row = mapFromItem(item, batchId, aiUserId, writeNode(item));
|
||||||
|
tosAssetMapper.insertTosAsset(row);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TosAsset row = new TosAsset();
|
||||||
|
row.setAiUserId(aiUserId);
|
||||||
|
row.setReviewBatchId(batchId);
|
||||||
|
row.setResultJson(decryptedJson);
|
||||||
|
if (StringUtils.isNotEmpty(requestSourceUrl)) {
|
||||||
|
row.setSourceUrl(requestSourceUrl);
|
||||||
|
}
|
||||||
|
row.setAssetId(text(root, "asset_id", "assetId"));
|
||||||
|
row.setAssetType(text(root, "asset_type", "assetType"));
|
||||||
|
row.setAssetUrl(text(root, "asset_url", "assetUrl"));
|
||||||
|
row.setDownstreamAssetId(text(root, "downstream_asset_id", "downstreamAssetId"));
|
||||||
|
row.setDownstreamFinalUrl(text(root, "downstream_final_url", "downstreamFinalUrl"));
|
||||||
|
row.setSourceUrl(coalesce(text(root, "source_url", "sourceUrl"), row.getSourceUrl()));
|
||||||
|
row.setTosUrl(text(root, "tos_url", "tosUrl"));
|
||||||
|
row.setSubmitReviewStatus(intOrNull(root, "submit_review_status", "submitReviewStatus", "code"));
|
||||||
|
tosAssetMapper.insertTosAsset(row);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("写入 tos_asset 失败", e);
|
||||||
|
throw new ServiceException("写入 tos_asset 失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TosAsset mapFromItem(JsonNode item, String batchId, Long aiUserId, String itemJson) {
|
||||||
|
TosAsset row = new TosAsset();
|
||||||
|
row.setAiUserId(aiUserId);
|
||||||
|
row.setReviewBatchId(batchId);
|
||||||
|
row.setResultJson(itemJson);
|
||||||
|
row.setAssetId(text(item, "asset_id", "assetId"));
|
||||||
|
row.setAssetType(text(item, "asset_type", "assetType"));
|
||||||
|
row.setAssetUrl(text(item, "asset_url", "assetUrl"));
|
||||||
|
row.setDownstreamAssetId(text(item, "downstream_asset_id", "downstreamAssetId"));
|
||||||
|
row.setDownstreamFinalUrl(text(item, "downstream_final_url", "downstreamFinalUrl"));
|
||||||
|
row.setSourceUrl(text(item, "source_url", "sourceUrl"));
|
||||||
|
row.setTosUrl(text(item, "tos_url", "tosUrl"));
|
||||||
|
row.setSubmitReviewStatus(intOrNull(item, "submit_review_status", "submitReviewStatus"));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String writeNode(JsonNode node) {
|
||||||
|
try {
|
||||||
|
return OM.writeValueAsString(node);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return node != null ? node.toString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode n, String... keys) {
|
||||||
|
for (String k : keys) {
|
||||||
|
if (n != null && n.has(k) && !n.get(k).isNull()) {
|
||||||
|
return n.get(k).asText(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstText(JsonNode n, String... keys) {
|
||||||
|
String t = text(n, keys);
|
||||||
|
return StringUtils.isNotEmpty(t) ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String coalesce(String a, String b) {
|
||||||
|
return StringUtils.isNotEmpty(a) ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intOrNull(JsonNode n, String... keys) {
|
||||||
|
for (String k : keys) {
|
||||||
|
if (n != null && n.has(k) && !n.get(k).isNull()) {
|
||||||
|
JsonNode v = n.get(k);
|
||||||
|
if (v.isInt() || v.isLong()) {
|
||||||
|
return v.intValue();
|
||||||
|
}
|
||||||
|
if (v.isNumber()) {
|
||||||
|
return v.intValue();
|
||||||
|
}
|
||||||
|
String s = v.asText(null);
|
||||||
|
if (StringUtils.isNotEmpty(s)) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(s.trim());
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// next key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String newReviewBatchId() {
|
||||||
|
String ts = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
|
||||||
|
return "review-" + ts + "-" + RandomStringUtil.generateRandomString(8).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String s, int max) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (s.length() <= max) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return s.substring(0, max) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package com.ruoyi.ai.service.impl;
|
||||||
|
|
||||||
|
import com.ruoyi.ai.domain.TosAsset;
|
||||||
|
import com.ruoyi.ai.mapper.TosAssetMapper;
|
||||||
|
import com.ruoyi.ai.service.ITosAssetService;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TosAssetServiceImpl implements ITosAssetService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TosAssetMapper tosAssetMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TosAsset> selectTosAssetList(TosAsset query, Long aiUserId) {
|
||||||
|
if (aiUserId == null) {
|
||||||
|
throw new ServiceException("未登录");
|
||||||
|
}
|
||||||
|
if (query == null) {
|
||||||
|
query = new TosAsset();
|
||||||
|
}
|
||||||
|
query.setAiUserId(aiUserId);
|
||||||
|
return tosAssetMapper.selectTosAssetList(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteByIdForUser(Long id, Long aiUserId) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new ServiceException("id 不能为空");
|
||||||
|
}
|
||||||
|
if (aiUserId == null) {
|
||||||
|
throw new ServiceException("未登录");
|
||||||
|
}
|
||||||
|
TosAsset row = tosAssetMapper.selectTosAssetById(id);
|
||||||
|
if (row == null) {
|
||||||
|
throw new ServiceException("记录不存在");
|
||||||
|
}
|
||||||
|
if (row.getAiUserId() == null || !row.getAiUserId().equals(aiUserId)) {
|
||||||
|
throw new ServiceException("无权删除该记录");
|
||||||
|
}
|
||||||
|
return tosAssetMapper.deleteTosAssetById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ruoyi.ai.mapper.TosAssetMapper">
|
||||||
|
|
||||||
|
<resultMap id="TosAssetResult" type="TosAsset">
|
||||||
|
<id property="id" column="id"/>
|
||||||
|
<result property="aiUserId" column="ai_user_id"/>
|
||||||
|
<result property="reviewBatchId" column="review_batch_id"/>
|
||||||
|
<result property="assetId" column="asset_id"/>
|
||||||
|
<result property="assetType" column="asset_type"/>
|
||||||
|
<result property="assetUrl" column="asset_url"/>
|
||||||
|
<result property="downstreamAssetId" column="downstream_asset_id"/>
|
||||||
|
<result property="downstreamFinalUrl" column="downstream_final_url"/>
|
||||||
|
<result property="sourceUrl" column="source_url"/>
|
||||||
|
<result property="submitReviewStatus" column="submit_review_status"/>
|
||||||
|
<result property="tosUrl" column="tos_url"/>
|
||||||
|
<result property="resultJson" column="result_json"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="selectTosAssetVo">
|
||||||
|
SELECT id, ai_user_id, review_batch_id, asset_id, asset_type, asset_url,
|
||||||
|
downstream_asset_id, downstream_final_url, source_url,
|
||||||
|
submit_review_status, tos_url, result_json
|
||||||
|
FROM tos_asset
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectTosAssetById" parameterType="Long" resultMap="TosAssetResult">
|
||||||
|
<include refid="selectTosAssetVo"/>
|
||||||
|
WHERE id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectTosAssetList" parameterType="TosAsset" resultMap="TosAssetResult">
|
||||||
|
<include refid="selectTosAssetVo"/>
|
||||||
|
<where>
|
||||||
|
<if test="aiUserId != null">AND ai_user_id = #{aiUserId}</if>
|
||||||
|
<if test="reviewBatchId != null and reviewBatchId != ''">AND review_batch_id = #{reviewBatchId}</if>
|
||||||
|
<if test="assetId != null and assetId != ''">AND asset_id = #{assetId}</if>
|
||||||
|
<if test="assetType != null and assetType != ''">AND asset_type = #{assetType}</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insertTosAsset" parameterType="TosAsset" useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO tos_asset (
|
||||||
|
ai_user_id,
|
||||||
|
review_batch_id,
|
||||||
|
asset_id,
|
||||||
|
asset_type,
|
||||||
|
asset_url,
|
||||||
|
downstream_asset_id,
|
||||||
|
downstream_final_url,
|
||||||
|
source_url,
|
||||||
|
submit_review_status,
|
||||||
|
tos_url,
|
||||||
|
result_json
|
||||||
|
) VALUES (
|
||||||
|
#{aiUserId},
|
||||||
|
#{reviewBatchId},
|
||||||
|
#{assetId},
|
||||||
|
#{assetType},
|
||||||
|
#{assetUrl},
|
||||||
|
#{downstreamAssetId},
|
||||||
|
#{downstreamFinalUrl},
|
||||||
|
#{sourceUrl},
|
||||||
|
#{submitReviewStatus},
|
||||||
|
#{tosUrl},
|
||||||
|
#{resultJson}
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="deleteTosAssetById" parameterType="Long">
|
||||||
|
DELETE FROM tos_asset WHERE id = #{id}
|
||||||
|
</delete>
|
||||||
|
</mapper>
|
||||||
Loading…
Reference in New Issue