fix: 客户定制需求,对接三方zhonglian

This commit is contained in:
old burden 2026-04-08 13:15:31 +08:00
parent 9250253238
commit 19adf685e0
27 changed files with 1353 additions and 219 deletions

View File

@ -64,38 +64,20 @@
<div class="vg-compose-mod vg-compose-mod--asset">
<div class="vg-mod-body vg-mod-body--asset">
<a-select
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 ? '请先选择素材组' : ''">
<span class="vg-asset-btn-wrap">
<mf-button
class="vg-compose-left-upload vg-mod-btn"
size="small"
:disabled="!hasAssetGroupId"
:loading="assetLoading"
@click="loadAssetsByGroup">
@click="loadTosAssetsForPicker">
选择资产
</mf-button>
</span>
<span
class="vg-asset-btn-wrap"
:title="!hasAssetGroupId ? '请先选择素材组' : ''">
<span class="vg-asset-btn-wrap">
<mf-button
class="vg-compose-left-upload vg-mod-btn"
size="small"
type="primary"
:disabled="!hasAssetGroupId"
@click="openFilePickerForAssetUpload">
上传资产
</mf-button>
@ -237,11 +219,11 @@
:ok-button-props="{ disabled: !assetPickerSelectedKeys.length }"
@ok="confirmPickAssets">
<div class="vg-asset-picker">
<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">
<div v-if="assetQueryResults.length === 0" class="vg-asset-picker-empty">暂无三方素材请先上传并过审</div>
<label v-for="it in assetQueryResults" :key="it.pickKey" class="vg-asset-picker-item">
<input
type="checkbox"
:value="String(it.assetId || it.id)"
:value="it.pickKey"
v-model="assetPickerSelectedKeys"
/>
<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>
<div v-else class="vg-audio-tile"> 音频</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>
</div>
</a-modal>
@ -261,24 +243,64 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
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) => ({
filter: { groupType: 'AIGC' },
pageNumber,
pageSize: ASSET_GROUP_LIST_PAGE_SIZE,
sortBy: 'CreateTime',
sortOrder: 'Desc'
})
/** COS/api/cos/upload + pathPrefix=asset与三方素材管理一致 */
const uploadViaCosAssetPrefix = async (file) => {
const form = new FormData()
form.append('file', file)
form.append('pathPrefix', 'asset')
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 value = String(g?.Id ?? g?.id ?? '').trim()
const name = String(g?.Name ?? g?.name ?? '').trim()
const extractModerationItems = (data) => {
if (!data) return []
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 {
value,
label: name || '-'
id: `tos_${assetId || it.id || Date.now()}`,
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 mentionVisible = ref(false)
const mentionActiveIndex = ref(-1)
const assetGroupId = ref('')
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 assetQueryResults = ref([])
const assetPickerSelectedKeys = ref([])
@ -370,13 +386,15 @@ const mentionCandidates = computed(() =>
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
const hasAssetRef = !!(i?.assetId || i?.assetUrl)
if (!hasAssetRef && !/^https?:\/\//i.test(u)) return false
return true
})
.map((i, idx) => ({
key: i.id || i.url || String(idx),
url: i.url,
assetId: i.assetId || '',
assetUrl: i.assetUrl || '',
mediaType: i.mediaType || 'image',
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 isReference = computed(() => props.videoMode === 'image-reference')
/** 参考图模式:已选素材组 ID与素材管理页一致非空才可选择/上传资产) */
const hasAssetGroupId = computed(() => !!String(assetGroupId.value || '').trim())
const setPrompt = (next) => {
localPrompt.value = next
emit('update:modelValue', next)
@ -400,23 +415,6 @@ const setMediaList = (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 = () => {
openReferenceDirectUpload()
}
@ -429,12 +427,8 @@ const openReferenceDirectUpload = () => {
fileInputRef.value?.click()
}
/** 仅「上传资产」:COS + createAsset 并写入素材组 */
/** 仅「上传资产」:/api/cos/upload(pathPrefix=asset) + /api/tos/asset与三方素材管理一致 */
const openFilePickerForAssetUpload = () => {
if (!hasAssetGroupId.value) {
Message.warning('请先选择素材组')
return
}
createAssetIntent.value = true
currentUploadIndex.value = -1
fileInputRef.value?.click()
@ -521,10 +515,12 @@ const clearAll = () => {
}
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]
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
next.push(item)
existing.add(key)
@ -533,52 +529,6 @@ const mergeAssetsToMediaList = (assets) => {
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(
() => props.videoMode,
(newMode) => {
@ -589,55 +539,36 @@ watch(
internalMediaList.value = saved
emit('update:mediaList', saved)
}
loadAssetGroups(true)
}
},
{ immediate: true }
)
const onAssetGroupDropdownReachBottom = () => {
loadAssetGroups(false)
}
const loadAssetsByGroup = async () => {
const gid = String(assetGroupId.value || '').trim()
if (!gid) {
Message.warning('请先选择素材组')
return
}
const loadTosAssetsForPicker = async () => {
assetLoading.value = true
try {
const res = await request({
url: 'api/byteAsset/listAssets',
method: 'POST',
data: buildReferenceListAssetsPayload()
url: 'api/tos/asset/list',
method: 'get',
data: {
pageNum: 1,
pageSize: TOS_ASSET_LIST_PAGE_SIZE
}
})
if (res.code !== 200) {
Message.error(res.msg || '查询素材失败')
Message.error(res.msg || '查询三方素材失败')
return
}
const rows = byteApiItems(res?.data)
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()))
assetQueryResults.value = assets
const rows = res.rows || []
const mapped = rows
.map(mapTosRowToPickerItem)
.filter((x) => x.assetUrl && x.url && /^https?:\/\//i.test(String(x.url || '').trim()))
assetQueryResults.value = mapped
assetPickerSelectedKeys.value = []
assetPickerVisible.value = true
Message.success(`查询到 ${assets.length} 条可用素材`)
Message.success(`可选取 ${mapped.length} 条三方素材`)
} catch (err) {
Message.error(err?.message || '查询素材失败')
Message.error(err?.message || '查询三方素材失败')
} finally {
assetLoading.value = false
}
@ -645,9 +576,7 @@ const loadAssetsByGroup = async () => {
const confirmPickAssets = () => {
const pickedKeys = new Set((assetPickerSelectedKeys.value || []).map((x) => String(x)))
const picked = (assetQueryResults.value || []).filter((x) =>
pickedKeys.has(String(x.assetId || x.id))
)
const picked = (assetQueryResults.value || []).filter((x) => pickedKeys.has(String(x.pickKey)))
mergeAssetsToMediaList(picked)
assetPickerVisible.value = false
assetPickerSelectedKeys.value = []
@ -658,10 +587,6 @@ const confirmPickAssets = () => {
const isReferenceMode = isReference.value
const wantCreateAsset = isReferenceMode && options.createAsset === true
if (wantCreateAsset && !String(assetGroupId.value || '').trim()) {
Message.warning('请先选择素材组')
return
}
let targetList = [...mediaList.value]
if (isFirstLastFrame.value && currentUploadIndex.value >= 0) {
@ -726,6 +651,65 @@ const confirmPickAssets = () => {
await nextTick()
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) {
try {
const res = await uploadFile({
@ -735,41 +719,17 @@ const confirmPickAssets = () => {
})
const url = extractUploadUrlFromResponse(res)
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
setMediaList(
mediaList.value.map((x) =>
normalizeItemKey(x) === normalizeItemKey(entry)
? { ...x, url, assetId, isUploading: false }
? { ...x, url, assetId: entry.assetId || '', isUploading: false }
: x
)
)
if (isReferenceMode) {
Message.success(wantCreateAsset ? '已上传并写入素材库' : '已上传完成')
Message.success('已上传完成')
}
try {
@ -987,9 +947,10 @@ const getEditorPlainText = () => {
const refKeyForEl = (el) => {
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 url = (el.getAttribute('data-reference-url') || '').trim()
return `${kind}:${assetId || url}`
return `${kind}:${assetUrl || assetId || url}`
}
const getUniqueRefKeysInDocForKind = (kind) => {
@ -1015,7 +976,12 @@ const renumberAllReferenceMentions = () => {
let droppedExtra = false
for (const el of refs) {
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) {
el.remove()
continue
@ -1063,8 +1029,8 @@ const reconcileReferenceMentions = (nextList) => {
if (!editorRef.value) return
const allowed = new Set(
(nextList || [])
.filter((x) => ['image', 'video', 'audio'].includes(x?.mediaType) && (x?.assetId || x?.url))
.map((x) => `${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.assetUrl || x.assetId || x.url}`)
)
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
const k = refKeyForEl(el)
@ -1086,12 +1052,13 @@ const collectReferenceContentInOrder = () => {
const el = node
if (el.dataset?.mentionReference === '1') {
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 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
seen.add(key)
out.push({ kind, assetId, url })
out.push({ kind, assetId, url, assetUrl })
return
}
Array.from(el.childNodes).forEach(walk)
@ -1109,7 +1076,12 @@ const getImageReferenceContentItems = () => {
const first = { type: 'text', text: text || '' }
const refs = collectReferenceContentInOrder()
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') {
return { type: 'video_url', video_url: { url: urlStr }, role: 'reference_video' }
}
@ -1319,18 +1291,20 @@ const onEditorKeyup = (e) => {
const buildReferenceHolderElement = (item) => {
const kind = item.mediaType || 'image'
const displayUrl = String(item.url || item.assetUrl || '').trim()
const holder = document.createElement('span')
holder.className = 'vg-inline-ref'
holder.setAttribute('data-mention-reference', '1')
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-url', String(item.assetUrl || '').trim())
holder.setAttribute('data-reference-kind', kind)
holder.setAttribute('contenteditable', 'false')
if (kind === 'video') {
const v = document.createElement('video')
v.src = item.url
v.src = displayUrl
v.className = 'vg-inline-ref-video'
v.muted = true
v.playsInline = true
@ -1345,7 +1319,7 @@ const buildReferenceHolderElement = (item) => {
holder.appendChild(span)
} else {
const img = document.createElement('img')
img.src = item.url
img.src = displayUrl
img.alt = ''
img.setAttribute('draggable', 'false')
img.setAttribute('contenteditable', 'false')
@ -1382,8 +1356,10 @@ const parseContentItemToMedia = (it, idx) => {
if (it?.type === 'image_url' && (!it.role || it.role === 'reference_image')) {
const raw = String(it.image_url?.url || '').trim()
if (!raw) return null
const assetId = raw.startsWith('asset://') ? raw.slice(9) : ''
return { id, url: raw, assetId, mediaType: 'image', name: '' }
if (/^asset:\/\//i.test(raw)) {
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') {
const raw = String(it.video_url?.url || '').trim()
@ -1409,8 +1385,9 @@ const findMediaItemForRefUrl = (items, url, kind) => {
items.find((x) => {
if ((x.mediaType || 'image') !== kind) return false
const xu = normRefKey(x.url)
const xau = normRefKey(x.assetUrl)
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 (xa && `asset://${xa}` === u) return true
return false
@ -1482,9 +1459,9 @@ const loadReferenceFromTaskRow = async (row) => {
}
const selectMentionItem = (item) => {
if (!item?.url || !editorRef.value) return
if ((!item?.url && !item?.assetUrl) || !editorRef.value) return
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)
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'

View File

@ -10,5 +10,6 @@ export default {
help: 'مركز المساعدة',
moneyInvite: 'دعوة مكافآت',
assetGroupManage: 'إدارة مجموعات الأصول',
assetManage: 'إدارة الأصول'
assetManage: 'إدارة الأصول',
thirdPartyAsset: 'أصول الطرف الثالث'
}

View File

@ -11,5 +11,6 @@ export default {
help: 'Help Center',
moneyInvite: 'Reward Invitation',
assetGroupManage: 'Asset Group Manage',
assetManage: 'Asset Manage'
assetManage: 'Asset Manage',
thirdPartyAsset: 'Third-party assets'
}

View File

@ -10,5 +10,6 @@ export default {
help: 'Centro de ayuda',
moneyInvite: 'Invitación con recompensa',
assetGroupManage: 'Gestión de grupos de activos',
assetManage: 'Gestión de activos'
assetManage: 'Gestión de activos',
thirdPartyAsset: 'Activos de terceros'
}

View File

@ -10,5 +10,6 @@ export default {
help: 'Centre d\'aide',
moneyInvite: 'Invitation avec récompense',
assetGroupManage: 'Gestion des groupes d\'actifs',
assetManage: 'Gestion des actifs'
assetManage: 'Gestion des actifs',
thirdPartyAsset: 'Actifs tiers'
}

View File

@ -10,5 +10,6 @@ export default {
help: 'सहायता केंद्र',
moneyInvite: 'इनाम निमंत्रण',
assetGroupManage: 'एसेट समूह प्रबंधन',
assetManage: 'एसेट प्रबंधन'
assetManage: 'एसेट प्रबंधन',
thirdPartyAsset: 'तीसरे पक्ष की सामग्री'
}

View File

@ -10,5 +10,6 @@ export default {
help: 'Central de ajuda',
moneyInvite: 'Convite com recompensa',
assetGroupManage: 'Gerenciamento de grupos de ativos',
assetManage: 'Gerenciamento de ativos'
assetManage: 'Gerenciamento de ativos',
thirdPartyAsset: 'Ativos de terceiros'
}

View File

@ -10,5 +10,6 @@ export default {
help: 'Центр помощи',
moneyInvite: 'Приглашение с наградой',
assetGroupManage: 'Управление группами ресурсов',
assetManage: 'Управление ресурсами'
assetManage: 'Управление ресурсами',
thirdPartyAsset: 'Сторонние материалы'
}

View File

@ -11,5 +11,6 @@ export default {
help: '幫助中心',
moneyInvite: '有獎邀請',
assetGroupManage: '資源組管理',
assetManage: '素材管理'
assetManage: '素材管理',
thirdPartyAsset: '三方素材管理'
}

View File

@ -47,7 +47,7 @@ import { constantRoutes } from '@/router/index'
import { generateTitle, generateLang } from '@/utils/i18n'
/** 左侧导航仅显示这些路由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({
collapsed: Boolean
@ -149,11 +149,8 @@ const getMenuSvg = (key) => {
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>'
}
if (key === 'asset-group-manage') {
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>'
}
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>'
if (key === 'third-party-asset') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h7l2 2h7v12H4V6Zm2 4v8h12v-8H6Zm2 2h8v4H8v-4Z"/></svg>'
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>'
}

View File

@ -129,6 +129,16 @@ export const constantRoutes = [{
permission: "pass",
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',
name: 'asset-group-manage',

View File

@ -42,7 +42,10 @@ const logout = () => {
*/
service.interceptors.request.use(
(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'] =
'multipart/form-data;boundary = ' + new Date().getTime()
}

View File

@ -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>

View File

@ -1132,8 +1132,9 @@ export default {
// https url content/reference_url assetId
const firstPreview = attachments.find((x) => x?.mediaType === 'image')
if (firstPreview) {
const au = String(firstPreview?.assetUrl || '').trim()
const aid = String(firstPreview?.assetId || '').trim()
params.referenceUrl = aid ? `asset://${aid}` : firstPreview.url
params.referenceUrl = au || (aid ? `asset://${aid}` : firstPreview.url)
}
}

View File

@ -1,6 +1,7 @@
package com.ruoyi.api;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.TencentCosUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@ -28,8 +29,11 @@ public class CosController {
@PostMapping("/upload")
public AjaxResult upload(
@ApiParam(name = "file", value = "文件", required = true)
@RequestParam("file") MultipartFile file) throws Exception {
String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true);
@RequestParam("file") MultipartFile file,
@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);
ajax.put("url", uploadUrl);
ajax.put("oldName", file.getOriginalFilename());

View File

@ -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());
}
}
}

View File

@ -247,6 +247,14 @@ volcengine:
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:
video:
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist请插入对应 type 或改此处与库一致

View File

@ -55,6 +55,13 @@ public class TencentCosUtil {
* @return 文件访问地址URL字符串
*/
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()) {
throw new IllegalArgumentException("上传文件不能为空");
}
@ -63,6 +70,10 @@ public class TencentCosUtil {
// 生成唯一文件键格式与AWS一致yyyy/MM/dd/uuid_filename
String key = generateCosKey(file.getOriginalFilename());
if (objectKeyPrefix != null && !objectKeyPrefix.trim().isEmpty()) {
String p = objectKeyPrefix.trim().replaceAll("^/+", "").replaceAll("/+$", "");
key = p + "/" + key;
}
try {
InputStream inputStream = file.getInputStream();

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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.aesHexKey64 位十六进制 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) + "...";
}
}

View File

@ -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);
}
}

View File

@ -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>