fix: 客户定制需求,对接三方zhonglian
This commit is contained in:
parent
9250253238
commit
19adf685e0
|
|
@ -64,38 +64,20 @@
|
|||
|
||||
<div class="vg-compose-mod vg-compose-mod--asset">
|
||||
<div class="vg-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' ? '音频' : '图片'
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ export default {
|
|||
help: 'مركز المساعدة',
|
||||
moneyInvite: 'دعوة مكافآت',
|
||||
assetGroupManage: 'إدارة مجموعات الأصول',
|
||||
assetManage: 'إدارة الأصول'
|
||||
assetManage: 'إدارة الأصول',
|
||||
thirdPartyAsset: 'أصول الطرف الثالث'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ export default {
|
|||
help: 'सहायता केंद्र',
|
||||
moneyInvite: 'इनाम निमंत्रण',
|
||||
assetGroupManage: 'एसेट समूह प्रबंधन',
|
||||
assetManage: 'एसेट प्रबंधन'
|
||||
assetManage: 'एसेट प्रबंधन',
|
||||
thirdPartyAsset: 'तीसरे पक्ष की सामग्री'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ export default {
|
|||
help: 'Центр помощи',
|
||||
moneyInvite: 'Приглашение с наградой',
|
||||
assetGroupManage: 'Управление группами ресурсов',
|
||||
assetManage: 'Управление ресурсами'
|
||||
assetManage: 'Управление ресурсами',
|
||||
thirdPartyAsset: 'Сторонние материалы'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ export default {
|
|||
help: '幫助中心',
|
||||
moneyInvite: '有獎邀請',
|
||||
assetGroupManage: '資源組管理',
|
||||
assetManage: '素材管理'
|
||||
assetManage: '素材管理',
|
||||
thirdPartyAsset: '三方素材管理'
|
||||
}
|
||||
|
|
@ -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>'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,439 @@
|
|||
<template>
|
||||
<div class="tpa-page">
|
||||
<section class="tpa-panel">
|
||||
<div class="tpa-head">
|
||||
<h3 class="tpa-title">三方素材管理</h3>
|
||||
<a-button type="primary" @click="openCreateDialog">新增素材</a-button>
|
||||
</div>
|
||||
<p class="tpa-desc">
|
||||
提交审核通过后,素材会入库并展示在下方列表,可继续查询或删除。
|
||||
</p>
|
||||
|
||||
<div class="tpa-filter">
|
||||
<a-input v-model="filters.reviewBatchId" allow-clear placeholder="审核批次 ID" style="width: 220px" />
|
||||
<a-button type="primary" :loading="listLoading" @click="loadList(1)">查询</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="listLoading">
|
||||
<div class="tpa-total">共 {{ total }} 条</div>
|
||||
<div class="tpa-table-wrap">
|
||||
<table class="tpa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>批次</th>
|
||||
<th>资产ID</th>
|
||||
<th>类型</th>
|
||||
<th>审核状态</th>
|
||||
<th>来源 URL</th>
|
||||
<th>TOS URL</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="tpa-ellipsis" :title="row.reviewBatchId">{{ row.reviewBatchId }}</td>
|
||||
<td class="tpa-ellipsis" :title="row.assetId">{{ row.assetId || '-' }}</td>
|
||||
<td>{{ formatAssetType(row.assetType) }}</td>
|
||||
<td>{{ formatReviewStatus(row.submitReviewStatus) }}</td>
|
||||
<td>
|
||||
<div v-if="row.sourceUrl" class="tpa-source-preview">
|
||||
<img v-if="isImageType(row.assetType)" :src="row.sourceUrl" alt="来源图片" />
|
||||
<video v-else-if="isVideoType(row.assetType)" :src="row.sourceUrl" controls preload="metadata"></video>
|
||||
<audio v-else-if="isAudioType(row.assetType)" :src="row.sourceUrl" controls preload="none"></audio>
|
||||
<a v-else :href="row.sourceUrl" target="_blank" rel="noopener">打开</a>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="tpa-ellipsis">
|
||||
<a v-if="row.tosUrl" :href="row.tosUrl" target="_blank" rel="noopener">打开</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<a-button type="outline" status="danger" size="mini" :loading="deletingId === row.id" @click="removeRow(row)">
|
||||
删除
|
||||
</a-button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="tpa-empty">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a-pagination
|
||||
class="tpa-pagination"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
show-total
|
||||
@change="onPageChange" />
|
||||
</a-spin>
|
||||
</section>
|
||||
|
||||
<a-modal v-model:visible="createDialogVisible" title="新增素材" :footer="false" :mask-closable="false" width="680px">
|
||||
<div class="tpa-upload-block">
|
||||
<div class="tpa-type-select">
|
||||
<span class="tpa-type-label">类型选择:</span>
|
||||
<a-radio-group v-model="createMediaType" type="button" @change="onCreateTypeChange">
|
||||
<a-radio value="image">图片</a-radio>
|
||||
<a-radio value="video">视频</a-radio>
|
||||
<a-radio value="audio">音频</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="tpa-upload-label">添加素材,拖拽或者点击上传</div>
|
||||
<a-upload
|
||||
:key="uploaderKey"
|
||||
draggable
|
||||
multiple
|
||||
:accept="uploadAcceptByType(createMediaType)"
|
||||
:custom-request="customRequest"
|
||||
:show-file-list="true"
|
||||
@change="onUploadChange" />
|
||||
<div class="tpa-upload-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="!pendingUrls.length"
|
||||
@click="submitModeration">
|
||||
提交审核
|
||||
</a-button>
|
||||
<a-button @click="cancelCreateDialog">取消</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
export default {
|
||||
name: 'ThirdPartyAsset',
|
||||
data() {
|
||||
return {
|
||||
createDialogVisible: false,
|
||||
createMediaType: 'image',
|
||||
uploaderKey: 0,
|
||||
pendingUrls: [],
|
||||
submitLoading: false,
|
||||
listLoading: false,
|
||||
rows: [],
|
||||
total: 0,
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
filters: {
|
||||
reviewBatchId: ''
|
||||
},
|
||||
deletingId: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadList(1)
|
||||
},
|
||||
methods: {
|
||||
isImageType(assetType) {
|
||||
return String(assetType || '').toLowerCase().includes('image')
|
||||
},
|
||||
isAudioType(assetType) {
|
||||
return String(assetType || '').toLowerCase().includes('audio')
|
||||
},
|
||||
isVideoType(assetType) {
|
||||
return String(assetType || '').toLowerCase().includes('video')
|
||||
},
|
||||
formatAssetType(assetType) {
|
||||
if (this.isImageType(assetType)) return '图片'
|
||||
if (this.isAudioType(assetType)) return '音频'
|
||||
if (this.isVideoType(assetType)) return '视频'
|
||||
return '-'
|
||||
},
|
||||
formatReviewStatus(status) {
|
||||
const code = Number(status)
|
||||
if (code === 1) return '审核通过'
|
||||
if (code === 0) return '待审核'
|
||||
return status != null ? String(status) : '-'
|
||||
},
|
||||
openCreateDialog() {
|
||||
this.createDialogVisible = true
|
||||
},
|
||||
onCreateTypeChange() {
|
||||
this.clearPendingUploads()
|
||||
},
|
||||
uploadAcceptByType(mediaType) {
|
||||
if (mediaType === 'audio') return 'audio/*'
|
||||
if (mediaType === 'video') return 'video/*'
|
||||
return 'image/*'
|
||||
},
|
||||
mediaDataKeyByType(mediaType) {
|
||||
if (mediaType === 'audio') return 'audios'
|
||||
if (mediaType === 'video') return 'videos'
|
||||
return 'images'
|
||||
},
|
||||
customRequest(option) {
|
||||
const { onProgress, onError, onSuccess, fileItem } = option
|
||||
const form = new FormData()
|
||||
form.append('file', fileItem.file)
|
||||
form.append('pathPrefix', 'asset')
|
||||
this.$axios({
|
||||
url: 'api/cos/upload',
|
||||
method: 'post',
|
||||
data: form,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data;boundary=' + new Date().getTime()
|
||||
},
|
||||
onUploadProgress: (evt) => {
|
||||
if (evt.total) onProgress(evt.loaded / evt.total, evt)
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.code === 200 && res.url) {
|
||||
this.pendingUrls.push(res.url)
|
||||
onSuccess(res)
|
||||
} else {
|
||||
const msg = (res && res.msg) || '上传失败'
|
||||
Message.error(msg)
|
||||
onError(new Error(msg))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(err)
|
||||
})
|
||||
},
|
||||
onUploadChange(_list, fileItem) {
|
||||
const st = fileItem && fileItem.status
|
||||
if (st === 'removed' && fileItem.response && fileItem.response.url) {
|
||||
const u = fileItem.response.url
|
||||
const idx = this.pendingUrls.findIndex((x) => x === u)
|
||||
if (idx >= 0) {
|
||||
this.pendingUrls.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
clearPendingUploads() {
|
||||
this.pendingUrls = []
|
||||
this.uploaderKey += 1
|
||||
},
|
||||
cancelCreateDialog() {
|
||||
this.clearPendingUploads()
|
||||
this.createDialogVisible = false
|
||||
},
|
||||
async submitModeration() {
|
||||
if (!this.pendingUrls.length) {
|
||||
Message.warning('请先上传素材')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
[this.mediaDataKeyByType(this.createMediaType)]: [...this.pendingUrls]
|
||||
}
|
||||
this.submitLoading = true
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: 'api/tos/asset',
|
||||
method: 'post',
|
||||
data: payload
|
||||
})
|
||||
if (res.code === 200) {
|
||||
Message.success('已提交审核')
|
||||
this.clearPendingUploads()
|
||||
this.createDialogVisible = false
|
||||
this.loadList(1)
|
||||
} else {
|
||||
Message.error(res.msg || '提交失败')
|
||||
}
|
||||
} catch (e) {
|
||||
Message.error(typeof e === 'string' ? e : e?.message || '提交失败')
|
||||
} finally {
|
||||
this.submitLoading = false
|
||||
}
|
||||
},
|
||||
async loadList(page) {
|
||||
this.pageNum = page || this.pageNum
|
||||
this.listLoading = true
|
||||
try {
|
||||
const q = {
|
||||
pageNum: this.pageNum,
|
||||
pageSize: this.pageSize
|
||||
}
|
||||
if (this.filters.reviewBatchId) {
|
||||
q.reviewBatchId = this.filters.reviewBatchId
|
||||
}
|
||||
const res = await this.$axios({
|
||||
url: 'api/tos/asset/list',
|
||||
method: 'get',
|
||||
data: q
|
||||
})
|
||||
if (res.code === 200) {
|
||||
this.rows = res.rows || []
|
||||
this.total = res.total || 0
|
||||
}
|
||||
} finally {
|
||||
this.listLoading = false
|
||||
}
|
||||
},
|
||||
onPageChange(p) {
|
||||
this.pageNum = p
|
||||
this.loadList(p)
|
||||
},
|
||||
async removeRow(row) {
|
||||
if (!row || !row.id) return
|
||||
this.deletingId = row.id
|
||||
try {
|
||||
const res = await this.$axios({
|
||||
url: `api/tos/asset/${row.id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
if (res.code === 200) {
|
||||
Message.success('已删除')
|
||||
this.loadList(this.pageNum)
|
||||
} else {
|
||||
Message.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
Message.error(typeof e === 'string' ? e : e?.message || '删除失败')
|
||||
} finally {
|
||||
this.deletingId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tpa-page {
|
||||
padding: 20px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tpa-panel {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.tpa-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tpa-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.tpa-desc {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
line-height: 1.5;
|
||||
code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.tpa-upload-block {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.tpa-upload-label {
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 14px;
|
||||
}
|
||||
.tpa-type-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tpa-type-label {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
}
|
||||
.tpa-upload-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tpa-filter {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tpa-total {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tpa-table-wrap {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 360px);
|
||||
overflow: auto;
|
||||
}
|
||||
.tpa-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
th,
|
||||
td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
}
|
||||
a {
|
||||
color: #6ad0ff;
|
||||
}
|
||||
}
|
||||
.tpa-source-preview {
|
||||
width: 140px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
img,
|
||||
video {
|
||||
width: 140px;
|
||||
max-height: 86px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
audio {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
.tpa-ellipsis {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tpa-empty {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
padding: 24px !important;
|
||||
}
|
||||
.tpa-pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1132,8 +1132,9 @@ export default {
|
|||
// 展示用仍然可以是 https url,但提交 content/reference_url 要与 assetId 关联。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package com.ruoyi.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ruoyi.ai.domain.TosAsset;
|
||||
import com.ruoyi.ai.domain.dto.TosAssetSubmitRequest;
|
||||
import com.ruoyi.ai.service.IModerationImageService;
|
||||
import com.ruoyi.ai.service.ITosAssetService;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* TOS 图片审核:加密对接第三方 /api/moderation/image,解密结果落库 tos_asset。
|
||||
*/
|
||||
@Slf4j
|
||||
@Api(tags = "TOS-审核资产")
|
||||
@RestController
|
||||
@RequestMapping("/api/tos")
|
||||
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||
public class TosAssetController extends BaseController {
|
||||
|
||||
private final IModerationImageService moderationImageService;
|
||||
private final ITosAssetService tosAssetService;
|
||||
|
||||
@GetMapping("/asset/list")
|
||||
@ApiOperation("分页查询当前用户的 tos_asset")
|
||||
public TableDataInfo list(TosAsset query) {
|
||||
startPage();
|
||||
return getDataTable(tosAssetService.selectTosAssetList(query, SecurityUtils.getAiUserId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/asset/{id}")
|
||||
@ApiOperation("删除单条 tos_asset(仅本人数据)")
|
||||
public AjaxResult<Void> remove(@PathVariable Long id) {
|
||||
try {
|
||||
tosAssetService.deleteByIdForUser(id, SecurityUtils.getAiUserId());
|
||||
return AjaxResult.success();
|
||||
} catch (ServiceException e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/asset")
|
||||
@ApiOperation("提交图片审核(加密转发 + 解密落库)")
|
||||
public AjaxResult<JsonNode> submitAsset(@RequestBody TosAssetSubmitRequest request) {
|
||||
try {
|
||||
JsonNode data = moderationImageService.submitImageModeration(request, SecurityUtils.getAiUserId());
|
||||
return AjaxResult.success(data);
|
||||
} catch (ServiceException e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("TOS 图片审核失败", e);
|
||||
return AjaxResult.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -247,6 +247,14 @@ volcengine:
|
|||
callBackUrl: http://47.86.170.114:5173/
|
||||
|
||||
# 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举
|
||||
# 第三方图片素材审核(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 或改此处与库一致
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.ruoyi.common.utils.crypto;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* AES-256-ECB + PKCS5Padding(与 PKCS7 在 16 字节块上等价),密钥为 32 字节。
|
||||
* 密文传输形态:Base64 字符串。
|
||||
*/
|
||||
public final class Aes256EcbPkcs5 {
|
||||
|
||||
private static final int AES_KEY_BYTES = 32;
|
||||
private static final String AES_ECB_PKCS5 = "AES/ECB/PKCS5Padding";
|
||||
|
||||
private Aes256EcbPkcs5() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param hex 64 位十六进制字符串(32 字节密钥)
|
||||
*/
|
||||
public static byte[] hexStringToBytes(String hex) {
|
||||
if (hex == null || hex.length() != AES_KEY_BYTES * 2) {
|
||||
throw new IllegalArgumentException("AES-256 hex key must be 64 hex characters");
|
||||
}
|
||||
int n = hex.length() / 2;
|
||||
byte[] out = new byte[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
int hi = Character.digit(hex.charAt(i * 2), 16);
|
||||
int lo = Character.digit(hex.charAt(i * 2 + 1), 16);
|
||||
if (hi < 0 || lo < 0) {
|
||||
throw new IllegalArgumentException("invalid hex in AES key");
|
||||
}
|
||||
out[i] = (byte) ((hi << 4) + lo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static String encryptToBase64(String plainUtf8, byte[] key256) throws GeneralSecurityException {
|
||||
if (plainUtf8 == null) {
|
||||
throw new IllegalArgumentException("plain text must not be null");
|
||||
}
|
||||
if (key256 == null || key256.length != AES_KEY_BYTES) {
|
||||
throw new IllegalArgumentException("AES key must be 32 bytes");
|
||||
}
|
||||
Cipher cipher = Cipher.getInstance(AES_ECB_PKCS5);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key256, "AES"));
|
||||
byte[] cipherBytes = cipher.doFinal(plainUtf8.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(cipherBytes);
|
||||
}
|
||||
|
||||
public static String decryptFromBase64(String base64Cipher, byte[] key256) throws GeneralSecurityException {
|
||||
if (base64Cipher == null || base64Cipher.isEmpty()) {
|
||||
throw new IllegalArgumentException("empty ciphertext");
|
||||
}
|
||||
if (key256 == null || key256.length != AES_KEY_BYTES) {
|
||||
throw new IllegalArgumentException("AES key must be 32 bytes");
|
||||
}
|
||||
byte[] raw = Base64.getDecoder().decode(base64Cipher.trim());
|
||||
Cipher cipher = Cipher.getInstance(AES_ECB_PKCS5);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key256, "AES"));
|
||||
byte[] plain = cipher.doFinal(raw);
|
||||
return new String(plain, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.ruoyi.ai.domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* TOS 审核资产条目表 tos_asset
|
||||
*/
|
||||
@Data
|
||||
public class TosAsset {
|
||||
|
||||
private Long id;
|
||||
private Long aiUserId;
|
||||
private String reviewBatchId;
|
||||
private String assetId;
|
||||
private String assetType;
|
||||
private String assetUrl;
|
||||
private String downstreamAssetId;
|
||||
private String downstreamFinalUrl;
|
||||
private String sourceUrl;
|
||||
private Integer submitReviewStatus;
|
||||
private String tosUrl;
|
||||
/** 解密后的业务 JSON 片段备份(单条或整段) */
|
||||
private String resultJson;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.ruoyi.ai.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 门户提交图片审核:明文入参(服务端加密后转发第三方)
|
||||
*/
|
||||
@Data
|
||||
public class TosAssetSubmitRequest {
|
||||
|
||||
/** 图片 URL 列表 */
|
||||
private List<String> images;
|
||||
|
||||
/** 音频 URL 列表 */
|
||||
private List<String> audios;
|
||||
|
||||
/** 视频 URL 列表 */
|
||||
private List<String> videos;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.ruoyi.ai.mapper;
|
||||
|
||||
import com.ruoyi.ai.domain.TosAsset;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* TOS 审核资产
|
||||
*/
|
||||
public interface TosAssetMapper {
|
||||
|
||||
int insertTosAsset(TosAsset row);
|
||||
|
||||
TosAsset selectTosAssetById(Long id);
|
||||
|
||||
List<TosAsset> selectTosAssetList(TosAsset query);
|
||||
|
||||
int deleteTosAssetById(Long id);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.ruoyi.ai.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ruoyi.ai.domain.dto.TosAssetSubmitRequest;
|
||||
|
||||
/**
|
||||
* 第三方图片审核:AES 加密对接 + 解密结果落库 tos_asset
|
||||
*/
|
||||
public interface IModerationImageService {
|
||||
|
||||
/**
|
||||
* 提交图片审核,解密响应后写入 tos_asset,并返回解密后的业务 JSON。
|
||||
*
|
||||
* @param aiUserId 仅用于本库 tos_asset.ai_user_id,不作为审核接口入参
|
||||
*/
|
||||
JsonNode submitImageModeration(TosAssetSubmitRequest request, Long aiUserId);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.ruoyi.ai.service;
|
||||
|
||||
import com.ruoyi.ai.domain.TosAsset;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 三方审核资产 tos_asset 门户侧查询、删除
|
||||
*/
|
||||
public interface ITosAssetService {
|
||||
|
||||
List<TosAsset> selectTosAssetList(TosAsset query, Long aiUserId);
|
||||
|
||||
int deleteByIdForUser(Long id, Long aiUserId);
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ruoyi.ai.domain.TosAsset;
|
||||
import com.ruoyi.ai.domain.dto.TosAssetSubmitRequest;
|
||||
import com.ruoyi.ai.mapper.TosAssetMapper;
|
||||
import com.ruoyi.ai.service.IModerationImageService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.RandomStringUtil;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.crypto.Aes256EcbPkcs5;
|
||||
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.security.GeneralSecurityException;
|
||||
|
||||
/**
|
||||
* 文档:图片素材审核 /api/moderation/image,请求体 encrypted_data 为 AES-256-ECB 加密后的业务 JSON。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ModerationImageServiceImpl implements IModerationImageService {
|
||||
|
||||
private static final ObjectMapper OM = new ObjectMapper();
|
||||
private static final MediaType JSON_MEDIA = MediaType.parse("application/json; charset=utf-8");
|
||||
|
||||
@Autowired
|
||||
private TosAssetMapper tosAssetMapper;
|
||||
|
||||
@Value("${moderation.image.url:http://118.196.112.236:3428/api/moderation/image}")
|
||||
private String moderationUrl;
|
||||
|
||||
/** 64 位十六进制 AES-256 密钥(your_64_char_hex_key) */
|
||||
@Value("${moderation.image.aesHexKey:}")
|
||||
private String aesHexKey;
|
||||
|
||||
/** 审核接口必填 user_id */
|
||||
@Value("${moderation.image.user_id:}")
|
||||
private String moderationUserId;
|
||||
|
||||
/** 审核接口可选 encryption_key_id(兼容字段) */
|
||||
@Value("${moderation.image.encryption_key_id:}")
|
||||
private String encryptionKeyId;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public JsonNode submitImageModeration(TosAssetSubmitRequest request, Long aiUserId) {
|
||||
if (request == null) {
|
||||
throw new ServiceException("请求体不能为空");
|
||||
}
|
||||
List<String> imageUrls = normalizeMediaUrls(request.getImages());
|
||||
List<String> audioUrls = normalizeMediaUrls(request.getAudios());
|
||||
List<String> videoUrls = normalizeMediaUrls(request.getVideos());
|
||||
if (imageUrls.isEmpty() && audioUrls.isEmpty() && videoUrls.isEmpty()) {
|
||||
throw new ServiceException("images、audios、videos 不能同时为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(aesHexKey) || aesHexKey.trim().length() != 64) {
|
||||
throw new ServiceException("未配置审核密钥:请配置 moderation.image.aesHexKey(64 位十六进制 AES-256)");
|
||||
}
|
||||
if (StringUtils.isEmpty(moderationUserId)) {
|
||||
throw new ServiceException("未配置审核参数:请配置 moderation.image.user_id");
|
||||
}
|
||||
if (StringUtils.isEmpty(moderationUrl)) {
|
||||
throw new ServiceException("未配置审核地址 moderation.image.url");
|
||||
}
|
||||
|
||||
final byte[] keyBytes;
|
||||
try {
|
||||
keyBytes = Aes256EcbPkcs5.hexStringToBytes(aesHexKey.trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ServiceException("moderation.image.aesHexKey 格式无效:" + e.getMessage());
|
||||
}
|
||||
|
||||
String reviewBatchId = newReviewBatchId();
|
||||
|
||||
ObjectNode biz = OM.createObjectNode();
|
||||
ArrayNode images = biz.putArray("images");
|
||||
for (String img : imageUrls) {
|
||||
images.add(img);
|
||||
}
|
||||
ArrayNode audios = biz.putArray("audios");
|
||||
for (String audio : audioUrls) {
|
||||
audios.add(audio);
|
||||
}
|
||||
ArrayNode videos = biz.putArray("videos");
|
||||
for (String video : videoUrls) {
|
||||
videos.add(video);
|
||||
}
|
||||
|
||||
final String encryptedPayload;
|
||||
try {
|
||||
String plain = OM.writeValueAsString(biz);
|
||||
encryptedPayload = Aes256EcbPkcs5.encryptToBase64(plain, keyBytes);
|
||||
} catch (Exception e) {
|
||||
log.warn("审核请求明文序列化或加密失败", e);
|
||||
throw new ServiceException("审核请求加密失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
ObjectNode upstream = OM.createObjectNode();
|
||||
upstream.put("encrypted_data", encryptedPayload);
|
||||
upstream.put("user_id", moderationUserId.trim());
|
||||
if (StringUtils.isNotEmpty(encryptionKeyId)) {
|
||||
upstream.put("encryption_key_id", encryptionKeyId.trim());
|
||||
} else {
|
||||
throw new ServiceException("未配置审核参数:请配置 moderation.image.encryption_key_id");
|
||||
}
|
||||
|
||||
final String responseBody;
|
||||
try {
|
||||
OkHttpClient client = OkHttpUtils.createOkHttpClient();
|
||||
String bodyJson = OM.writeValueAsString(upstream);
|
||||
RequestBody rb = RequestBody.create(JSON_MEDIA, bodyJson);
|
||||
Request httpReq = new Request.Builder().url(moderationUrl.trim()).post(rb).build();
|
||||
try (Response resp = client.newCall(httpReq).execute()) {
|
||||
if (resp.body() == null) {
|
||||
throw new ServiceException("审核服务无响应体,HTTP " + resp.code());
|
||||
}
|
||||
responseBody = resp.body().string();
|
||||
if (!resp.isSuccessful()) {
|
||||
throw new ServiceException("审核服务 HTTP " + resp.code() + ":" + truncate(responseBody, 500));
|
||||
}
|
||||
}
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("调用审核接口失败", e);
|
||||
throw new ServiceException("调用审核接口失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
final JsonNode root;
|
||||
try {
|
||||
root = OM.readTree(responseBody);
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("审核响应非 JSON:" + truncate(responseBody, 300));
|
||||
}
|
||||
|
||||
if (!root.has("encrypted_data") || StringUtils.isEmpty(root.get("encrypted_data").asText())) {
|
||||
String msg = root.path("message").asText(root.path("msg").asText("审核服务返回错误"));
|
||||
throw new ServiceException(msg);
|
||||
}
|
||||
|
||||
final String decrypted;
|
||||
try {
|
||||
decrypted = Aes256EcbPkcs5.decryptFromBase64(root.get("encrypted_data").asText(), keyBytes);
|
||||
} catch (IllegalArgumentException | GeneralSecurityException e) {
|
||||
log.warn("审核响应解密失败", e);
|
||||
throw new ServiceException("审核响应解密失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
JsonNode business;
|
||||
try {
|
||||
business = OM.readTree(decrypted);
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("解密后内容非 JSON:" + truncate(decrypted, 300));
|
||||
}
|
||||
|
||||
persistDecrypted(decrypted, reviewBatchId, aiUserId, firstSourceUrl(imageUrls, audioUrls, videoUrls));
|
||||
return business;
|
||||
}
|
||||
|
||||
private static List<String> normalizeMediaUrls(List<String> input) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (input == null || input.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
for (String u : input) {
|
||||
if (StringUtils.isNotEmpty(u)) {
|
||||
out.add(u.trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String firstSourceUrl(List<String> imageUrls, List<String> audioUrls, List<String> videoUrls) {
|
||||
if (imageUrls != null && !imageUrls.isEmpty()) {
|
||||
return imageUrls.get(0);
|
||||
}
|
||||
if (audioUrls != null && !audioUrls.isEmpty()) {
|
||||
return audioUrls.get(0);
|
||||
}
|
||||
if (videoUrls != null && !videoUrls.isEmpty()) {
|
||||
return videoUrls.get(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void persistDecrypted(String decryptedJson, String defaultBatchId, Long aiUserId, String requestSourceUrl) {
|
||||
try {
|
||||
JsonNode root = OM.readTree(decryptedJson);
|
||||
String batchId = firstText(root, "review_batch_id", "reviewBatchId");
|
||||
if (StringUtils.isEmpty(batchId)) {
|
||||
batchId = defaultBatchId;
|
||||
}
|
||||
|
||||
if (root.has("items") && root.get("items").isArray() && root.get("items").size() > 0) {
|
||||
for (JsonNode item : root.get("items")) {
|
||||
TosAsset row = mapFromItem(item, batchId, aiUserId, writeNode(item));
|
||||
tosAssetMapper.insertTosAsset(row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
TosAsset row = new TosAsset();
|
||||
row.setAiUserId(aiUserId);
|
||||
row.setReviewBatchId(batchId);
|
||||
row.setResultJson(decryptedJson);
|
||||
if (StringUtils.isNotEmpty(requestSourceUrl)) {
|
||||
row.setSourceUrl(requestSourceUrl);
|
||||
}
|
||||
row.setAssetId(text(root, "asset_id", "assetId"));
|
||||
row.setAssetType(text(root, "asset_type", "assetType"));
|
||||
row.setAssetUrl(text(root, "asset_url", "assetUrl"));
|
||||
row.setDownstreamAssetId(text(root, "downstream_asset_id", "downstreamAssetId"));
|
||||
row.setDownstreamFinalUrl(text(root, "downstream_final_url", "downstreamFinalUrl"));
|
||||
row.setSourceUrl(coalesce(text(root, "source_url", "sourceUrl"), row.getSourceUrl()));
|
||||
row.setTosUrl(text(root, "tos_url", "tosUrl"));
|
||||
row.setSubmitReviewStatus(intOrNull(root, "submit_review_status", "submitReviewStatus", "code"));
|
||||
tosAssetMapper.insertTosAsset(row);
|
||||
} catch (Exception e) {
|
||||
log.error("写入 tos_asset 失败", e);
|
||||
throw new ServiceException("写入 tos_asset 失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static TosAsset mapFromItem(JsonNode item, String batchId, Long aiUserId, String itemJson) {
|
||||
TosAsset row = new TosAsset();
|
||||
row.setAiUserId(aiUserId);
|
||||
row.setReviewBatchId(batchId);
|
||||
row.setResultJson(itemJson);
|
||||
row.setAssetId(text(item, "asset_id", "assetId"));
|
||||
row.setAssetType(text(item, "asset_type", "assetType"));
|
||||
row.setAssetUrl(text(item, "asset_url", "assetUrl"));
|
||||
row.setDownstreamAssetId(text(item, "downstream_asset_id", "downstreamAssetId"));
|
||||
row.setDownstreamFinalUrl(text(item, "downstream_final_url", "downstreamFinalUrl"));
|
||||
row.setSourceUrl(text(item, "source_url", "sourceUrl"));
|
||||
row.setTosUrl(text(item, "tos_url", "tosUrl"));
|
||||
row.setSubmitReviewStatus(intOrNull(item, "submit_review_status", "submitReviewStatus"));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String writeNode(JsonNode node) {
|
||||
try {
|
||||
return OM.writeValueAsString(node);
|
||||
} catch (Exception e) {
|
||||
return node != null ? node.toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode n, String... keys) {
|
||||
for (String k : keys) {
|
||||
if (n != null && n.has(k) && !n.get(k).isNull()) {
|
||||
return n.get(k).asText(null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String firstText(JsonNode n, String... keys) {
|
||||
String t = text(n, keys);
|
||||
return StringUtils.isNotEmpty(t) ? t : null;
|
||||
}
|
||||
|
||||
private static String coalesce(String a, String b) {
|
||||
return StringUtils.isNotEmpty(a) ? a : b;
|
||||
}
|
||||
|
||||
private static Integer intOrNull(JsonNode n, String... keys) {
|
||||
for (String k : keys) {
|
||||
if (n != null && n.has(k) && !n.get(k).isNull()) {
|
||||
JsonNode v = n.get(k);
|
||||
if (v.isInt() || v.isLong()) {
|
||||
return v.intValue();
|
||||
}
|
||||
if (v.isNumber()) {
|
||||
return v.intValue();
|
||||
}
|
||||
String s = v.asText(null);
|
||||
if (StringUtils.isNotEmpty(s)) {
|
||||
try {
|
||||
return Integer.parseInt(s.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// next key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String newReviewBatchId() {
|
||||
String ts = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
|
||||
return "review-" + ts + "-" + RandomStringUtil.generateRandomString(8).toLowerCase();
|
||||
}
|
||||
|
||||
private static String truncate(String s, int max) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
if (s.length() <= max) {
|
||||
return s;
|
||||
}
|
||||
return s.substring(0, max) + "...";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.ruoyi.ai.service.impl;
|
||||
|
||||
import com.ruoyi.ai.domain.TosAsset;
|
||||
import com.ruoyi.ai.mapper.TosAssetMapper;
|
||||
import com.ruoyi.ai.service.ITosAssetService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class TosAssetServiceImpl implements ITosAssetService {
|
||||
|
||||
@Autowired
|
||||
private TosAssetMapper tosAssetMapper;
|
||||
|
||||
@Override
|
||||
public List<TosAsset> selectTosAssetList(TosAsset query, Long aiUserId) {
|
||||
if (aiUserId == null) {
|
||||
throw new ServiceException("未登录");
|
||||
}
|
||||
if (query == null) {
|
||||
query = new TosAsset();
|
||||
}
|
||||
query.setAiUserId(aiUserId);
|
||||
return tosAssetMapper.selectTosAssetList(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteByIdForUser(Long id, Long aiUserId) {
|
||||
if (id == null) {
|
||||
throw new ServiceException("id 不能为空");
|
||||
}
|
||||
if (aiUserId == null) {
|
||||
throw new ServiceException("未登录");
|
||||
}
|
||||
TosAsset row = tosAssetMapper.selectTosAssetById(id);
|
||||
if (row == null) {
|
||||
throw new ServiceException("记录不存在");
|
||||
}
|
||||
if (row.getAiUserId() == null || !row.getAiUserId().equals(aiUserId)) {
|
||||
throw new ServiceException("无权删除该记录");
|
||||
}
|
||||
return tosAssetMapper.deleteTosAssetById(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.ai.mapper.TosAssetMapper">
|
||||
|
||||
<resultMap id="TosAssetResult" type="TosAsset">
|
||||
<id property="id" column="id"/>
|
||||
<result property="aiUserId" column="ai_user_id"/>
|
||||
<result property="reviewBatchId" column="review_batch_id"/>
|
||||
<result property="assetId" column="asset_id"/>
|
||||
<result property="assetType" column="asset_type"/>
|
||||
<result property="assetUrl" column="asset_url"/>
|
||||
<result property="downstreamAssetId" column="downstream_asset_id"/>
|
||||
<result property="downstreamFinalUrl" column="downstream_final_url"/>
|
||||
<result property="sourceUrl" column="source_url"/>
|
||||
<result property="submitReviewStatus" column="submit_review_status"/>
|
||||
<result property="tosUrl" column="tos_url"/>
|
||||
<result property="resultJson" column="result_json"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTosAssetVo">
|
||||
SELECT id, ai_user_id, review_batch_id, asset_id, asset_type, asset_url,
|
||||
downstream_asset_id, downstream_final_url, source_url,
|
||||
submit_review_status, tos_url, result_json
|
||||
FROM tos_asset
|
||||
</sql>
|
||||
|
||||
<select id="selectTosAssetById" parameterType="Long" resultMap="TosAssetResult">
|
||||
<include refid="selectTosAssetVo"/>
|
||||
WHERE id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectTosAssetList" parameterType="TosAsset" resultMap="TosAssetResult">
|
||||
<include refid="selectTosAssetVo"/>
|
||||
<where>
|
||||
<if test="aiUserId != null">AND ai_user_id = #{aiUserId}</if>
|
||||
<if test="reviewBatchId != null and reviewBatchId != ''">AND review_batch_id = #{reviewBatchId}</if>
|
||||
<if test="assetId != null and assetId != ''">AND asset_id = #{assetId}</if>
|
||||
<if test="assetType != null and assetType != ''">AND asset_type = #{assetType}</if>
|
||||
</where>
|
||||
ORDER BY id DESC
|
||||
</select>
|
||||
|
||||
<insert id="insertTosAsset" parameterType="TosAsset" useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO tos_asset (
|
||||
ai_user_id,
|
||||
review_batch_id,
|
||||
asset_id,
|
||||
asset_type,
|
||||
asset_url,
|
||||
downstream_asset_id,
|
||||
downstream_final_url,
|
||||
source_url,
|
||||
submit_review_status,
|
||||
tos_url,
|
||||
result_json
|
||||
) VALUES (
|
||||
#{aiUserId},
|
||||
#{reviewBatchId},
|
||||
#{assetId},
|
||||
#{assetType},
|
||||
#{assetUrl},
|
||||
#{downstreamAssetId},
|
||||
#{downstreamFinalUrl},
|
||||
#{sourceUrl},
|
||||
#{submitReviewStatus},
|
||||
#{tosUrl},
|
||||
#{resultJson}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<delete id="deleteTosAssetById" parameterType="Long">
|
||||
DELETE FROM tos_asset WHERE id = #{id}
|
||||
</delete>
|
||||
</mapper>
|
||||
Loading…
Reference in New Issue