From 19adf685e01d6e5aaa63097c0a38ab65f5a3ebb3 Mon Sep 17 00:00:00 2001 From: old burden Date: Wed, 8 Apr 2026 13:15:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=A2=E6=88=B7=E5=AE=9A=E5=88=B6?= =?UTF-8?q?=E9=9C=80=E6=B1=82=EF=BC=8C=E5=AF=B9=E6=8E=A5=E4=B8=89=E6=96=B9?= =?UTF-8?q?zhonglian?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal-ui/src/components/VideoComposeCard.vue | 379 +++++++-------- portal-ui/src/lang/ar_SA/route.js | 3 +- portal-ui/src/lang/en_US/route.js | 3 +- portal-ui/src/lang/es_ES/route.js | 3 +- portal-ui/src/lang/fr_FR/route.js | 3 +- portal-ui/src/lang/hi_IN/route.js | 3 +- portal-ui/src/lang/pt_BR/route.js | 3 +- portal-ui/src/lang/ru_RU/route.js | 3 +- portal-ui/src/lang/zh_HK/route.js | 3 +- portal-ui/src/layout/components/Menu.vue | 9 +- portal-ui/src/router/index.js | 10 + portal-ui/src/utils/request.js | 5 +- portal-ui/src/views/ThirdPartyAsset.vue | 439 ++++++++++++++++++ portal-ui/src/views/VideoGen.vue | 3 +- .../java/com/ruoyi/api/CosController.java | 8 +- .../com/ruoyi/api/TosAssetController.java | 70 +++ .../src/main/resources/application.yml | 8 + .../ruoyi/common/utils/TencentCosUtil.java | 11 + .../common/utils/crypto/Aes256EcbPkcs5.java | 67 +++ .../java/com/ruoyi/ai/domain/TosAsset.java | 24 + .../ai/domain/dto/TosAssetSubmitRequest.java | 21 + .../com/ruoyi/ai/mapper/TosAssetMapper.java | 19 + .../ai/service/IModerationImageService.java | 17 + .../ruoyi/ai/service/ITosAssetService.java | 15 + .../impl/ModerationImageServiceImpl.java | 320 +++++++++++++ .../ai/service/impl/TosAssetServiceImpl.java | 47 ++ .../mapper/system/TosAssetMapper.xml | 76 +++ 27 files changed, 1353 insertions(+), 219 deletions(-) create mode 100644 portal-ui/src/views/ThirdPartyAsset.vue create mode 100644 web-api/ruoyi-admin/src/main/java/com/ruoyi/api/TosAssetController.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/Aes256EcbPkcs5.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/TosAsset.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/dto/TosAssetSubmitRequest.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/TosAssetMapper.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IModerationImageService.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/ITosAssetService.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ModerationImageServiceImpl.java create mode 100644 web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/TosAssetServiceImpl.java create mode 100644 web-api/ruoyi-system/src/main/resources/mapper/system/TosAssetMapper.xml diff --git a/portal-ui/src/components/VideoComposeCard.vue b/portal-ui/src/components/VideoComposeCard.vue index 1efb459..47bde21 100644 --- a/portal-ui/src/components/VideoComposeCard.vue +++ b/portal-ui/src/components/VideoComposeCard.vue @@ -64,38 +64,20 @@
- - - {{ g.label }} - - - + + @click="loadTosAssetsForPicker"> 选择资产 - + 上传资产 @@ -237,11 +219,11 @@ :ok-button-props="{ disabled: !assetPickerSelectedKeys.length }" @ok="confirmPickAssets">
-
当前分组暂无可用素材
-
@@ -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' ? '音频' : '图片' diff --git a/portal-ui/src/lang/ar_SA/route.js b/portal-ui/src/lang/ar_SA/route.js index 363fa86..607b436 100644 --- a/portal-ui/src/lang/ar_SA/route.js +++ b/portal-ui/src/lang/ar_SA/route.js @@ -10,5 +10,6 @@ export default { help: 'مركز المساعدة', moneyInvite: 'دعوة مكافآت', assetGroupManage: 'إدارة مجموعات الأصول', - assetManage: 'إدارة الأصول' + assetManage: 'إدارة الأصول', + thirdPartyAsset: 'أصول الطرف الثالث' } diff --git a/portal-ui/src/lang/en_US/route.js b/portal-ui/src/lang/en_US/route.js index fa335e9..fa6a78a 100644 --- a/portal-ui/src/lang/en_US/route.js +++ b/portal-ui/src/lang/en_US/route.js @@ -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' } \ No newline at end of file diff --git a/portal-ui/src/lang/es_ES/route.js b/portal-ui/src/lang/es_ES/route.js index ca0a45c..9000ef8 100644 --- a/portal-ui/src/lang/es_ES/route.js +++ b/portal-ui/src/lang/es_ES/route.js @@ -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' } diff --git a/portal-ui/src/lang/fr_FR/route.js b/portal-ui/src/lang/fr_FR/route.js index e5c70e3..884af36 100644 --- a/portal-ui/src/lang/fr_FR/route.js +++ b/portal-ui/src/lang/fr_FR/route.js @@ -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' } diff --git a/portal-ui/src/lang/hi_IN/route.js b/portal-ui/src/lang/hi_IN/route.js index 212dfdd..81138ec 100644 --- a/portal-ui/src/lang/hi_IN/route.js +++ b/portal-ui/src/lang/hi_IN/route.js @@ -10,5 +10,6 @@ export default { help: 'सहायता केंद्र', moneyInvite: 'इनाम निमंत्रण', assetGroupManage: 'एसेट समूह प्रबंधन', - assetManage: 'एसेट प्रबंधन' + assetManage: 'एसेट प्रबंधन', + thirdPartyAsset: 'तीसरे पक्ष की सामग्री' } diff --git a/portal-ui/src/lang/pt_BR/route.js b/portal-ui/src/lang/pt_BR/route.js index 6305688..959ef10 100644 --- a/portal-ui/src/lang/pt_BR/route.js +++ b/portal-ui/src/lang/pt_BR/route.js @@ -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' } diff --git a/portal-ui/src/lang/ru_RU/route.js b/portal-ui/src/lang/ru_RU/route.js index 3e033db..b1f1bb0 100644 --- a/portal-ui/src/lang/ru_RU/route.js +++ b/portal-ui/src/lang/ru_RU/route.js @@ -10,5 +10,6 @@ export default { help: 'Центр помощи', moneyInvite: 'Приглашение с наградой', assetGroupManage: 'Управление группами ресурсов', - assetManage: 'Управление ресурсами' + assetManage: 'Управление ресурсами', + thirdPartyAsset: 'Сторонние материалы' } diff --git a/portal-ui/src/lang/zh_HK/route.js b/portal-ui/src/lang/zh_HK/route.js index d452cbc..6d83149 100644 --- a/portal-ui/src/lang/zh_HK/route.js +++ b/portal-ui/src/lang/zh_HK/route.js @@ -11,5 +11,6 @@ export default { help: '幫助中心', moneyInvite: '有獎邀請', assetGroupManage: '資源組管理', - assetManage: '素材管理' + assetManage: '素材管理', + thirdPartyAsset: '三方素材管理' } \ No newline at end of file diff --git a/portal-ui/src/layout/components/Menu.vue b/portal-ui/src/layout/components/Menu.vue index 9ff8af2..4eefeba 100644 --- a/portal-ui/src/layout/components/Menu.vue +++ b/portal-ui/src/layout/components/Menu.vue @@ -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 '' } - if (key === 'asset-group-manage') { - return '' - } - if (key === 'asset-manage') { - return '' + if (key === 'third-party-asset') { + return '' } return '' } diff --git a/portal-ui/src/router/index.js b/portal-ui/src/router/index.js index c3c96ae..70e91bd 100644 --- a/portal-ui/src/router/index.js +++ b/portal-ui/src/router/index.js @@ -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', diff --git a/portal-ui/src/utils/request.js b/portal-ui/src/utils/request.js index cb64eaa..4a54635 100644 --- a/portal-ui/src/utils/request.js +++ b/portal-ui/src/utils/request.js @@ -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() } diff --git a/portal-ui/src/views/ThirdPartyAsset.vue b/portal-ui/src/views/ThirdPartyAsset.vue new file mode 100644 index 0000000..bf92e63 --- /dev/null +++ b/portal-ui/src/views/ThirdPartyAsset.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/portal-ui/src/views/VideoGen.vue b/portal-ui/src/views/VideoGen.vue index 7d4b582..275bd24 100644 --- a/portal-ui/src/views/VideoGen.vue +++ b/portal-ui/src/views/VideoGen.vue @@ -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) } } diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java index d188e10..ff6480c 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java @@ -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()); diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/TosAssetController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/TosAssetController.java new file mode 100644 index 0000000..ea3a5af --- /dev/null +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/TosAssetController.java @@ -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 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 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()); + } + } +} diff --git a/web-api/ruoyi-admin/src/main/resources/application.yml b/web-api/ruoyi-admin/src/main/resources/application.yml index 98d5a40..9f30045 100644 --- a/web-api/ruoyi-admin/src/main/resources/application.yml +++ b/web-api/ruoyi-admin/src/main/resources/application.yml @@ -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 或改此处与库一致 diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/TencentCosUtil.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/TencentCosUtil.java index 1612c9e..690a2e4 100644 --- a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/TencentCosUtil.java +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/TencentCosUtil.java @@ -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(); diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/Aes256EcbPkcs5.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/Aes256EcbPkcs5.java new file mode 100644 index 0000000..fad6c7a --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/Aes256EcbPkcs5.java @@ -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); + } +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/TosAsset.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/TosAsset.java new file mode 100644 index 0000000..97aafaa --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/TosAsset.java @@ -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; +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/dto/TosAssetSubmitRequest.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/dto/TosAssetSubmitRequest.java new file mode 100644 index 0000000..dddadb6 --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/dto/TosAssetSubmitRequest.java @@ -0,0 +1,21 @@ +package com.ruoyi.ai.domain.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 门户提交图片审核:明文入参(服务端加密后转发第三方) + */ +@Data +public class TosAssetSubmitRequest { + + /** 图片 URL 列表 */ + private List images; + + /** 音频 URL 列表 */ + private List audios; + + /** 视频 URL 列表 */ + private List videos; +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/TosAssetMapper.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/TosAssetMapper.java new file mode 100644 index 0000000..66d03f2 --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/TosAssetMapper.java @@ -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 selectTosAssetList(TosAsset query); + + int deleteTosAssetById(Long id); +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IModerationImageService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IModerationImageService.java new file mode 100644 index 0000000..c82a622 --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IModerationImageService.java @@ -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); +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/ITosAssetService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/ITosAssetService.java new file mode 100644 index 0000000..461fd6b --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/ITosAssetService.java @@ -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 selectTosAssetList(TosAsset query, Long aiUserId); + + int deleteByIdForUser(Long id, Long aiUserId); +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ModerationImageServiceImpl.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ModerationImageServiceImpl.java new file mode 100644 index 0000000..e141691 --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ModerationImageServiceImpl.java @@ -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 imageUrls = normalizeMediaUrls(request.getImages()); + List audioUrls = normalizeMediaUrls(request.getAudios()); + List 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 normalizeMediaUrls(List input) { + List 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 imageUrls, List audioUrls, List 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) + "..."; + } +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/TosAssetServiceImpl.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/TosAssetServiceImpl.java new file mode 100644 index 0000000..6a4935b --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/TosAssetServiceImpl.java @@ -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 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); + } +} diff --git a/web-api/ruoyi-system/src/main/resources/mapper/system/TosAssetMapper.xml b/web-api/ruoyi-system/src/main/resources/mapper/system/TosAssetMapper.xml new file mode 100644 index 0000000..34c1a11 --- /dev/null +++ b/web-api/ruoyi-system/src/main/resources/mapper/system/TosAssetMapper.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + 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} + ) + + + + DELETE FROM tos_asset WHERE id = #{id} + +