修改:jingdong的识别主体接口替换
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build was killed

This commit is contained in:
Linzm committed 2026-11-04 18:37:18 +08:00
1 parent b25e3ca473
commit 4979739521
4 files changed
+173 -123

No files matched your search

+39 -6
View File
@@ -754,6 +754,23 @@ export const confirmUpload = (input: { session_id: string; upload_id: string })
}) })
} }
/**
* POST /jd/subject/classify
* 图片上传完成后识别主体(session_id + upload_id)
*/
export const classifySubject = (input: {
session_id: string
upload_id: string
}) => {
return request('jd/subject/classify', {
method: 'POST',
data: {
session_id: input.session_id,
upload_id: input.upload_id,
},
})
}
/** POST /jd/imagegen/submit */ /** POST /jd/imagegen/submit */
export const submitImageGen = (input: ImageGenSubmitInput) => { export const submitImageGen = (input: ImageGenSubmitInput) => {
return request(`${PREFIX}/imagegen/submit`, { return request(`${PREFIX}/imagegen/submit`, {
@@ -838,9 +855,10 @@ export const confirmOrder = (input: OrderConfirmInput) => {
} }
/** /**
* 上传原图:签发 URL → PUT OSS → confirm(需 session_id) * 导入阶段:签发 URL → PUT OSS(不 confirm)
* 拿到 upload_id 后可立刻调 subject/classify
*/ */
export async function uploadOriginal( export async function prepareUpload(
file: File | Blob, file: File | Blob,
options: { options: {
session_id: string session_id: string
@@ -857,10 +875,6 @@ export async function uploadOriginal(
purpose: options.purpose || 'origin', purpose: options.purpose || 'origin',
}) })
await putUpload(ticket.upload_url, file, ticket.content_type || contentType) await putUpload(ticket.upload_url, file, ticket.content_type || contentType)
await confirmUpload({
session_id: options.session_id,
upload_id: ticket.upload_id,
})
return { return {
uploadId: ticket.upload_id, uploadId: ticket.upload_id,
fileUrl: ticket.file_url, fileUrl: ticket.file_url,
@@ -869,3 +883,22 @@ export async function uploadOriginal(
} }
} }
/**
* 完整上传:签发 URL → PUT OSS → confirm(需 session_id)
*/
export async function uploadOriginal(
file: File | Blob,
options: {
session_id: string
filename?: string
purpose?: UploadPurpose
},
) {
const prepared = await prepareUpload(file, options)
await confirmUpload({
session_id: options.session_id,
upload_id: prepared.uploadId,
})
return prepared
}
+2 -1
View File
@@ -59,9 +59,10 @@ export const jingdongConfig = {
needEditText: '请输入想调整的内容后再发送', needEditText: '请输入想调整的内容后再发送',
needPhotoOrText: '请上传照片或输入描述后再发送', needPhotoOrText: '请上传照片或输入描述后再发送',
needProductTitle: (label: string) => `请填写${label}后再发送`, needProductTitle: (label: string) => `请填写${label}后再发送`,
uploadingTip: '正在上传照片,请稍候…',
inspectingTip: '正在识别照片,请稍候…', inspectingTip: '正在识别照片,请稍候…',
/** 仅提示,不拦截发送 */ /** 仅提示,不拦截发送 */
subjectMismatchTip: '上传图片的主体数量与下单主体数量不一致', subjectMismatchTip: '上传图片的主体数量与下单主体数量不一致,生图时候会按下单的主体数量自动调整主体',
subjectMismatchToast: (got: string, need: string) => subjectMismatchToast: (got: string, need: string) =>
`上传图片主体(${got})与下单主体(${need})数量不一致`, `上传图片主体(${got})与下单主体(${need})数量不一致`,
generating: '正在生成效果图,请稍候…', generating: '正在生成效果图,请稍候…',
+16 -73
View File
@@ -1,7 +1,5 @@
/** /**
* 原图分类 /classify(对齐 agent image-inspect) * 图片主体识别结果(由后端 jd/subject/classify 返回后规范化)
* - 本地:Vite 代理 `/concept-classify` → `/v1/classify`
* - 线上:直连 seetacloud
*/ */
export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain' export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain'
@@ -13,6 +11,8 @@ export interface ImageInspect {
petCount: number petCount: number
reviewStatus: string reviewStatus: string
conclusion: string conclusion: string
/** 主体识别 check_id,后续下单可用 */
checkId: number
} }
export class InspectCancelled extends Error { export class InspectCancelled extends Error {
@@ -22,10 +22,6 @@ export class InspectCancelled extends Error {
} }
} }
const INSPECT_TIMEOUT_MS = 120_000
const DEFAULT_INSPECT_HOST = 'https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443'
const DEFAULT_INSPECT_REMOTE_URL = `${DEFAULT_INSPECT_HOST}/v1/classify`
function asRecord(value: unknown) { function asRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
@@ -42,24 +38,6 @@ export function mapInspectImageType(raw: unknown): ImageKind {
return 'uncertain' return 'uncertain'
} }
function resolveInspectUrl() {
const fromEnv = String(
(import.meta as any).env?.VITE_CONCEPT_INSPECT_URL || '',
)
.trim()
.replace(/\/+$/, '')
if (fromEnv) {
if (/\/v1\/classify$/i.test(fromEnv)) return fromEnv
return `${fromEnv}/v1/classify`
}
if ((import.meta as any).env?.DEV) return '/concept-classify'
return DEFAULT_INSPECT_REMOTE_URL
}
function resolveInspectToken() {
return String((import.meta as any).env?.VITE_CONCEPT_INSPECT_TOKEN || '').trim()
}
function countField(data: Record<string, unknown>, keys: string[]) { function countField(data: Record<string, unknown>, keys: string[]) {
for (const key of keys) { for (const key of keys) {
const value = Number(data[key]) const value = Number(data[key])
@@ -68,9 +46,19 @@ function countField(data: Record<string, unknown>, keys: string[]) {
return 0 return 0
} }
function normalizeInspect(body: unknown): ImageInspect { /** 规范化 classify 接口 data(拦截器已解包 code=1000 的 data) */
export function normalizeInspect(body: unknown): ImageInspect {
const root = asRecord(body) || {} const root = asRecord(body) || {}
const data = asRecord(root.data) || root const nested = asRecord(root.data)
// 兼容完整响应 { code, data } / 已解包的 data 本体
const data =
nested &&
(nested.image_type != null ||
nested.person_count != null ||
nested.is_cartoon != null ||
nested.check_id != null)
? nested
: root
const imageType = mapInspectImageType(data.image_type ?? data.imageType) const imageType = mapInspectImageType(data.image_type ?? data.imageType)
const isCartoonRaw = data.is_cartoon ?? data.isCartoon const isCartoonRaw = data.is_cartoon ?? data.isCartoon
const isCartoon = const isCartoon =
@@ -87,6 +75,7 @@ function normalizeInspect(body: unknown): ImageInspect {
petCount: countField(data, ['pet_count', 'petCount']), petCount: countField(data, ['pet_count', 'petCount']),
reviewStatus: String(data.review_status || ''), reviewStatus: String(data.review_status || ''),
conclusion: String(data.conclusion || ''), conclusion: String(data.conclusion || ''),
checkId: countField(data, ['check_id', 'checkId']),
} }
} }
@@ -104,49 +93,3 @@ export function formatSubjectFromInspect(result: ImageInspect) {
if (result.petCount > 0) parts.push(`${result.petCount}宠物`) if (result.petCount > 0) parts.push(`${result.petCount}宠物`)
return parts.join('+') return parts.join('+')
} }
export async function inspectImage(file: File, signal?: AbortSignal) {
if (signal?.aborted) throw new InspectCancelled()
const url = resolveInspectUrl()
const token = resolveInspectToken()
const form = new FormData()
form.append('image', file, file.name || 'image.jpg')
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
const controller = new AbortController()
let timedOut = false
const timer = window.setTimeout(() => {
timedOut = true
controller.abort()
}, INSPECT_TIMEOUT_MS)
const onAbort = () => controller.abort()
signal?.addEventListener('abort', onAbort, { once: true })
try {
const response = await fetch(url, {
method: 'POST',
body: form,
headers,
signal: controller.signal,
})
if (!response.ok) {
if (response.status === 400) throw new Error('图片上传错误,请检查文件后重试')
if (response.status === 401) throw new Error('图片识别鉴权失败')
if (response.status === 413) throw new Error('图片过大,请压缩后重试')
throw new Error(`图片识别失败(${response.status})`)
}
const raw: unknown = await response.json()
return normalizeInspect(raw)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
if (timedOut) throw new Error('图片识别超时,请稍后重试')
throw new InspectCancelled()
}
if (error instanceof TypeError) throw new Error('图片识别失败,请稍后重试')
throw error instanceof Error ? error : new Error('图片识别失败')
} finally {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
}
}
+116 -43
View File
@@ -95,7 +95,7 @@
:editing-hint="config.messages.editingHint" :editing-hint="config.messages.editingHint"
:exit-edit-label="config.messages.exitEdit" :exit-edit-label="config.messages.exitEdit"
:disabled="isBusy" :disabled="isBusy"
:inspecting="inspecting" :inspecting="inspecting || uploading"
:can-send="canSend" :can-send="canSend"
:status-tip="composerStatusTip" :status-tip="composerStatusTip"
:tip-warn="composerTipWarn" :tip-warn="composerTipWarn"
@@ -124,8 +124,7 @@ import * as badgeApi from '@/api/badge'
import * as jingdongApi from '@/api/jingdong' import * as jingdongApi from '@/api/jingdong'
import { jingdongConfig as config } from '@/config/jingdong' import { jingdongConfig as config } from '@/config/jingdong'
import { import {
InspectCancelled, normalizeInspect,
inspectImage,
type ImageInspect, type ImageInspect,
} from '@/services/jingdong/image-inspect' } from '@/services/jingdong/image-inspect'
import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image' import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image'
@@ -219,16 +218,19 @@ const titleInvalid = ref(false)
const pendingPreview = ref('') const pendingPreview = ref('')
const pendingObjectUrl = ref('') const pendingObjectUrl = ref('')
const pendingFile = ref<File | null>(null) const pendingFile = ref<File | null>(null)
/** 选图后预上传得到的 upload_id,发送时复用 */
const pendingUploadId = ref('')
const blobUrls = new Set<string>() const blobUrls = new Set<string>()
const editingResultId = ref<string | null>(null) const editingResultId = ref<string | null>(null)
const isBusy = ref(false) const isBusy = ref(false)
const uploading = ref(false)
const inspecting = ref(false) const inspecting = ref(false)
const photoInspect = ref<ImageInspect | null>(null) const photoInspect = ref<ImageInspect | null>(null)
const lastUploadId = ref('') const lastUploadId = ref('')
const pendingFinish = ref<PendingFinish | null>(null) const pendingFinish = ref<PendingFinish | null>(null)
let inspectSerial = 0
let inspectAbort: AbortController | null = null
let pollActive = false let pollActive = false
/** 切换/清空图片时作废进行中的上传与识别 */
let uploadSerial = 0
function createBaseMessages(): ChatMessage[] { function createBaseMessages(): ChatMessage[] {
return [ return [
@@ -348,9 +350,11 @@ watch(
}, },
) )
const canSend = computed(() => { const canSend = computed(() => {
if (isBusy.value || inspecting.value) return false if (isBusy.value || uploading.value || inspecting.value) return false
if (remainCount.value <= 0) return false if (remainCount.value <= 0) return false
if (!hasRequiredTitle.value) return false if (!hasRequiredTitle.value) return false
// 上传未完成时不可发(避免缺少 upload_id)
if (pendingPreview.value && !pendingUploadId.value) return false
if (isEditing.value) { if (isEditing.value) {
// 编辑:需有飞入的效果图 + 调整文案 // 编辑:需有飞入的效果图 + 调整文案
return !!pendingPreview.value && draft.value.trim().length > 0 return !!pendingPreview.value && draft.value.trim().length > 0
@@ -362,6 +366,7 @@ const canSend = computed(() => {
const composerStatusTip = computed(() => { const composerStatusTip = computed(() => {
if (isBusy.value) return config.messages.generatingTip if (isBusy.value) return config.messages.generatingTip
if (uploading.value) return config.messages.uploadingTip
if (inspecting.value) return config.messages.inspectingTip if (inspecting.value) return config.messages.inspectingTip
if (remainCount.value <= 0) return config.messages.noRemain if (remainCount.value <= 0) return config.messages.noRemain
if (pendingPreview.value && !subjectMatched.value) return config.messages.subjectMismatchTip if (pendingPreview.value && !subjectMatched.value) return config.messages.subjectMismatchTip
@@ -448,6 +453,10 @@ function triggerUpload() {
showToast('编辑效果图时请直接描述想调整的内容') showToast('编辑效果图时请直接描述想调整的内容')
return return
} }
if (uploading.value) {
showToast(config.messages.uploadingTip)
return
}
if (inspecting.value) { if (inspecting.value) {
showToast(config.messages.inspectingTip) showToast(config.messages.inspectingTip)
return return
@@ -472,10 +481,11 @@ function revokePendingObjectUrl() {
* keepObjectUrl=true:气泡仍要用 blob 预览,只断开 pending 引用,不 revoke。 * keepObjectUrl=true:气泡仍要用 blob 预览,只断开 pending 引用,不 revoke。
*/ */
function clearPendingPreview(options?: { keepObjectUrl?: boolean }) { function clearPendingPreview(options?: { keepObjectUrl?: boolean }) {
inspectAbort?.abort() uploadSerial += 1
inspectAbort = null uploading.value = false
inspecting.value = false inspecting.value = false
pendingFile.value = null pendingFile.value = null
pendingUploadId.value = ''
photoInspect.value = null photoInspect.value = null
subjectMatched.value = true subjectMatched.value = true
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
@@ -488,6 +498,69 @@ function clearPendingPreview(options?: { keepObjectUrl?: boolean }) {
pendingPreview.value = '' pendingPreview.value = ''
} }
/**
* 导入阶段:upload/url(+ PUT OSS)→ subject/classify
* confirm 留到提交时再调
*/
async function startPendingUploadAndClassify(file: File) {
const serial = ++uploadSerial
pendingUploadId.value = ''
photoInspect.value = null
subjectMatched.value = true
const sid = String(sessionId.value || '').trim()
if (!sid) {
showToast(friendlyError('缺少会话信息,请刷新后重试', 'common'))
return
}
uploading.value = true
inspecting.value = false
try {
// 仅签发 url 并 PUT,不 confirm
const prepared = await jingdongApi.prepareUpload(file, {
session_id: sid,
purpose: 'origin',
})
if (serial !== uploadSerial) return
pendingUploadId.value = prepared.uploadId
lastUploadId.value = prepared.uploadId
} catch (error) {
if (serial !== uploadSerial) return
pendingUploadId.value = ''
showToast({
message: friendlyError(error, 'upload'),
duration: 2800,
})
return
} finally {
if (serial === uploadSerial) uploading.value = false
}
if (serial !== uploadSerial || !pendingUploadId.value) return
inspecting.value = true
try {
const classifyRaw = await jingdongApi.classifySubject({
session_id: sid,
upload_id: pendingUploadId.value,
})
if (serial !== uploadSerial) return
const inspectResult = normalizeInspect(classifyRaw)
photoInspect.value = inspectResult
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
checkSubjectMatch(inspectResult)
} catch (error) {
if (serial !== uploadSerial) return
showToast({
message: friendlyError(error, 'inspect'),
duration: 2800,
})
} finally {
if (serial === uploadSerial) inspecting.value = false
}
}
function onClearPreview() { function onClearPreview() {
clearPendingPreview() clearPendingPreview()
// 编辑态清掉预览图时同步退出编辑,保留已输入提示词 // 编辑态清掉预览图时同步退出编辑,保留已输入提示词
@@ -505,44 +578,25 @@ async function fileFromImageUrl(url: string, filename = 'effect.jpg') {
return new File([blob], name, { type }) return new File([blob], name, { type })
} }
async function runPhotoRecognize(file: File) {
inspectAbort?.abort()
const controller = new AbortController()
inspectAbort = controller
const serial = ++inspectSerial
inspecting.value = true
photoInspect.value = null
subjectMatched.value = true
try {
const inspectResult = await inspectImage(file, controller.signal)
if (serial !== inspectSerial) return
photoInspect.value = inspectResult
// 标签仍展示订单主体;识别结果只用于校验
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
checkSubjectMatch(inspectResult)
} catch (error) {
if (serial !== inspectSerial || error instanceof InspectCancelled) return
showToast(friendlyError(error, 'inspect'))
} finally {
if (serial === inspectSerial) inspecting.value = false
}
}
function onFileChange(e: Event) { function onFileChange(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
input.value = '' input.value = ''
if (!file) return if (!file) return
uploadSerial += 1
uploading.value = false
inspecting.value = false
photoInspect.value = null
subjectMatched.value = true
pendingUploadId.value = ''
revokePendingObjectUrl() revokePendingObjectUrl()
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file)
trackBlobUrl(url) trackBlobUrl(url)
pendingObjectUrl.value = url pendingObjectUrl.value = url
pendingPreview.value = url pendingPreview.value = url
pendingFile.value = file pendingFile.value = file
void runPhotoRecognize(file) // 导入后立刻上传,成功后再识别主体
void startPendingUploadAndClassify(file)
} }
function previewImages(images: string[], startPosition = 0) { function previewImages(images: string[], startPosition = 0) {
@@ -601,10 +655,12 @@ async function enterEdit(resultId: string, sourceRect?: FlyRect | null) {
// 先放入预览(飞入过程中隐藏),并准备重新上传用的文件 // 先放入预览(飞入过程中隐藏),并准备重新上传用的文件
pendingPreview.value = msg.image pendingPreview.value = msg.image
subjectMatched.value = true subjectMatched.value = true
pendingUploadId.value = ''
void fileFromImageUrl(msg.image) void fileFromImageUrl(msg.image)
.then((file) => { .then((file) => {
if (editingResultId.value !== resultId) return if (editingResultId.value !== resultId) return
pendingFile.value = file pendingFile.value = file
void startPendingUploadAndClassify(file)
}) })
.catch((error) => { .catch((error) => {
if (editingResultId.value === resultId) { if (editingResultId.value === resultId) {
@@ -778,17 +834,27 @@ async function startGenerate(opts: {
pendingFinish.value = null pendingFinish.value = null
try { try {
let uploadId = '' if (!opts.file && !pendingUploadId.value) {
if (!opts.file) {
throw new Error(opts.mode === 'edit' ? '缺少可编辑的效果图' : '请先上传照片') throw new Error(opts.mode === 'edit' ? '缺少可编辑的效果图' : '请先上传照片')
} }
// 卡通 / 真人统一走生图:上传 purpose=origin,再 imagegen/submit // 复用导入阶段的 upload_id;若无则兜底:url+PUT(仍不在这里 classify)
const uploaded = await jingdongApi.uploadOriginal(opts.file, { let uploadId = String(pendingUploadId.value || '').trim()
if (!uploadId) {
if (!opts.file) throw new Error(opts.mode === 'edit' ? '缺少可编辑的效果图' : '请先上传照片')
const prepared = await jingdongApi.prepareUpload(opts.file, {
session_id: sid,
purpose: 'origin',
})
uploadId = prepared.uploadId
pendingUploadId.value = uploadId
lastUploadId.value = uploadId
}
// 提交阶段:confirm → imagegen/submit
await jingdongApi.confirmUpload({
session_id: sid, session_id: sid,
purpose: 'origin', upload_id: uploadId,
}) })
uploadId = uploaded.uploadId
lastUploadId.value = uploadId
const styleId = resolveStyleId() const styleId = resolveStyleId()
const sceneProp = String(opts.sceneProp || '').trim() const sceneProp = String(opts.sceneProp || '').trim()
@@ -854,10 +920,18 @@ function onSend() {
showToast(config.messages.generatingTip) showToast(config.messages.generatingTip)
return return
} }
if (uploading.value) {
showToast(config.messages.uploadingTip)
return
}
if (inspecting.value) { if (inspecting.value) {
showToast(config.messages.inspectingTip) showToast(config.messages.inspectingTip)
return return
} }
if (pendingPreview.value && !pendingUploadId.value) {
showToast(config.messages.uploadingTip)
return
}
if (remainCount.value <= 0) { if (remainCount.value <= 0) {
showToast(config.messages.noRemain) showToast(config.messages.noRemain)
@@ -1023,7 +1097,6 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
pollActive = false pollActive = false
inspectAbort?.abort()
blobUrls.forEach((url) => URL.revokeObjectURL(url)) blobUrls.forEach((url) => URL.revokeObjectURL(url))
blobUrls.clear() blobUrls.clear()
pendingObjectUrl.value = '' pendingObjectUrl.value = ''