Files
ece.suwa3d.com/src/api/jingdong.ts
T
Linzm 0199b924ac
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
修改:京东demo页面编辑效果图流程调整
2026-11-09 15:53:14 +08:00

888 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import request from '@/utils/request'
import { localStorage } from '@/utils/local-storage'
/** 京东 BFF:与 agent 对话页同构接口,路径前缀为 /jd */
const PREFIX = 'jd'
function buildApiUrl(path: string) {
const base = String(process.env.VUE_APP_API_BASE_URL || '/api/').replace(/\/+$/, '')
return `${base}/${String(path).replace(/^\/+/, '')}`
}
function buildAuthHeaders() {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const code = localStorage.get('code')
const userId = localStorage.get('userId')
if (code) headers.Authorization = String(code)
if (userId) headers.Userid = String(userId)
return headers
}
function asRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function textOf(value: unknown) {
return value == null ? '' : String(value)
}
function listOf(value: unknown) {
return Array.isArray(value) ? value : []
}
function asCount(value: unknown) {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
export function normalizeOssUrl(url: string) {
if (!url) return ''
let s = String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=').trim()
if (s.indexOf('//') === 0) s = `https:${s}`
if (/^http:\/\//i.test(s)) s = `https://${s.slice(7)}`
return s
}
function imageUrlOf(item: Record<string, unknown>) {
return normalizeOssUrl(
textOf(item.url || item.file_url || item.image_url || item.cover_url || item.result_url),
)
}
function guessContentType(file: File | Blob, filename?: string) {
if (file.type) return file.type
const name = String(filename || '').toLowerCase()
if (name.endsWith('.png')) return 'image/png'
if (name.endsWith('.webp')) return 'image/webp'
if (name.endsWith('.gif')) return 'image/gif'
return 'image/jpeg'
}
function guessFilename(file?: File) {
if (file && (file as File).name) return (file as File).name
return 'photo.jpg'
}
function formatClock(value: unknown) {
const n = asCount(value)
if (!n) {
const s = textOf(value)
if (/^\d{1,2}:\d{2}$/.test(s)) return s
return ''
}
const ms = n > 1e12 ? n : n * 1000
const d = new Date(ms)
if (Number.isNaN(d.getTime())) return ''
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
// ---------- 类型(对齐 agent-api) ----------
export type UploadPurpose = 'origin' | 'external_effect'
export type ImageGenStatusCode = 'pending' | 'processing' | 'success' | 'failed'
export interface UploadTicket {
upload_id: string
upload_url: string
file_url: string
content_type: string
expire_at: number
}
export interface ImageGenSubmitInput {
session_id: string
/** 导入生图传 upload_id;编辑效果图不传 */
upload_id?: string
/** 编辑效果图传 image_id;导入生图不传 */
image_id?: string
prompt?: string
scene_prop?: string
custom_text?: string
/** 效果图编辑 edit;原图编辑 / 普通立即生成 generate */
mode?: 'generate' | 'edit'
}
export interface ImageGenImage {
image_id: string
path: string
url: string
index: number
}
export interface ImageGenTask {
task_id: string
status: ImageGenStatusCode
}
export interface ImageGenStatus {
task_id: string
status: ImageGenStatusCode
error: string
style: string
prompt: string
images: ImageGenImage[]
}
export interface EffectSubmitInput {
session_id: string
upload_id: string
product_id: number
product_name?: string
user_message?: string
mounts_id?: number
scene_prop?: string
custom_text?: string
base_type?: number
frame_type?: number
mount_id?: number
pid?: number
proportion_id?: number
pose_id?: number
style_id?: number
prompt?: string
}
export interface EffectSubmitResult {
task_id: string
status: ImageGenStatusCode
error: string
image_id: string
url: string
images: ImageGenImage[]
}
export interface OrderConfirmInput {
task_id: string
image_id?: string
cate_id: number
product_id?: number
top_prod_id?: number
shop_id: number
print_size?: number
quantity?: number
contact_name?: string
contact_mobile?: string
address?: string
country_id?: number
province_id?: string
city_id?: string
county_id?: string
figure_type: number
check_id?: number
out_trade_no?: string
remark?: string
shape_id?: number
font_id?: number
custom_text?: string
back_text?: string
up_text?: string
down_text?: string
}
export interface OrderConfirmResult {
order_id: number
pid: number
message: string
out_trade_no?: string
}
export interface SessionDetailUpload {
upload_id: string
status: string
purpose: UploadPurpose | ''
path: string
url: string
}
export interface SessionDetailImage {
image_id: string
path: string
url: string
index: number
}
export interface SessionDetailTask {
task_id: string
upload_id: string
status: string
error: string
source: string
provider: string
product_name: string
style_code: string
style_name: string
prompt: string
n: number
pid: number
/** generate | edit,编辑效果图时右侧气泡只展示提示词 */
mode: 'generate' | 'edit' | ''
created_at: number
completed_at: number
duration: number
images: SessionDetailImage[]
}
export const SESSION_RECORD_TYPES = [
'session_created',
'upload',
'upload_confirmed',
'imagegen_submit',
'imagegen_success',
'imagegen_failed',
'external_effect',
'order',
] as const
export type SessionRecordType = (typeof SESSION_RECORD_TYPES)[number]
export interface SessionDetailRecord {
type: SessionRecordType | ''
at: number
message: string
upload_id: string
task_id: string
status: string
product_name: string
image_count: number
}
export interface SessionDetail {
session_id: string
shop_id: number
customer_name: string
subtitle: string
created_at: number
updated_at: number
uploads: SessionDetailUpload[]
tasks: SessionDetailTask[]
records: SessionDetailRecord[]
}
export type SessionHistoryItem =
| {
role: 'user'
text: string
image?: string
time?: string
taskId?: string
uploadId?: string
}
| {
role: 'agent'
type: 'result'
image: string
meta?: string
time?: string
durationSec?: number
taskId?: string
imageId?: string
uploadId?: string
prompt?: string
}
export function isExternalEffect(value: string | undefined) {
return value === 'external_effect'
}
// ---------- 解析 ----------
function uploadPurpose(value: unknown): UploadPurpose | '' {
return value === 'origin' || value === 'external_effect' ? value : ''
}
function sessionRecordType(value: unknown): SessionRecordType | '' {
const text = textOf(value).trim().toLowerCase()
return (SESSION_RECORD_TYPES as readonly string[]).includes(text)
? (text as SessionRecordType)
: ''
}
function normalizeDetailImage(raw: unknown): SessionDetailImage | null {
const item = asRecord(raw)
if (!item) return null
const url = imageUrlOf(item)
if (!url) return null
return {
image_id: textOf(item.image_id || item.id),
path: textOf(item.path),
url,
index: asCount(item.index),
}
}
function normalizeDetailTask(raw: unknown): SessionDetailTask | null {
const item = asRecord(raw)
if (!item || !item.task_id) return null
const images = listOf(item.images ?? item.image_list ?? item.results)
.map((image) => normalizeDetailImage(image))
.filter((image): image is SessionDetailImage => Boolean(image))
.sort((a, b) => a.index - b.index)
if (!images.length) {
const url = imageUrlOf(item)
if (url) {
images.push({
image_id: textOf(item.image_id || item.id),
path: textOf(item.path),
url,
index: 0,
})
}
}
const modeRaw = textOf(item.mode || item.image_gen_mode || item.imageGenMode).toLowerCase()
const mode: SessionDetailTask['mode'] =
modeRaw === 'edit' || modeRaw === 'generate' ? modeRaw : ''
return {
task_id: textOf(item.task_id),
upload_id: textOf(item.upload_id),
status: textOf(item.status),
error: textOf(item.error || item.message),
source: textOf(item.source),
provider: textOf(item.provider),
product_name: textOf(item.product_name),
style_code: textOf(item.style_code),
style_name: textOf(item.style_name),
prompt: textOf(item.prompt),
n: asCount(item.n),
pid: asCount(item.pid),
mode,
created_at: asCount(item.created_at),
completed_at: asCount(item.completed_at),
duration: asCount(item.duration),
images,
}
}
function normalizeDetailRecord(raw: unknown): SessionDetailRecord | null {
const item = asRecord(raw)
if (!item) return null
return {
type: sessionRecordType(item.type),
at: asCount(item.at),
message: textOf(item.message),
upload_id: textOf(item.upload_id),
task_id: textOf(item.task_id),
status: textOf(item.status),
product_name: textOf(item.product_name),
image_count: asCount(item.image_count),
}
}
/** 解析 session/detail(对齐 agent normalizeSessionDetail) */
export function normalizeSessionDetail(data: unknown): SessionDetail {
const record = asRecord(data) || {}
const uploads = listOf(record.uploads)
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item && item.upload_id))
.map((item) => ({
upload_id: textOf(item.upload_id),
status: textOf(item.status),
purpose: uploadPurpose(item.purpose),
path: textOf(item.path),
url: normalizeOssUrl(textOf(item.url)),
}))
const tasks = listOf(record.tasks)
.map((item) => normalizeDetailTask(item))
.filter((item): item is SessionDetailTask => Boolean(item))
.sort((a, b) => a.created_at - b.created_at)
const records = listOf(record.records)
.map((item) => normalizeDetailRecord(item))
.filter((item): item is SessionDetailRecord => Boolean(item))
.sort((a, b) => a.at - b.at)
return {
session_id: textOf(record.session_id),
shop_id: asCount(record.shop_id),
customer_name: textOf(record.customer_name),
subtitle: textOf(record.subtitle),
created_at: asCount(record.created_at),
updated_at: asCount(record.updated_at || record.created_at),
uploads,
tasks,
records,
}
}
function uploadOf(detail: SessionDetail, uploadId: string) {
return uploadId ? detail.uploads.find((item) => item.upload_id === uploadId) : undefined
}
function isBroughtTask(detail: SessionDetail, task: SessionDetailTask) {
const upload = uploadOf(detail, task.upload_id)
return (
isExternalEffect(upload?.purpose) ||
isExternalEffect(task.source) ||
task.provider === 'user_effect'
)
}
/**
* 将 session/detail 转为对话气泡历史(对齐 agent applySessionDetail → turns)
*/
export function sessionDetailToHistory(detail: SessionDetail): SessionHistoryItem[] {
const history: SessionHistoryItem[] = []
const defaultPrompt = '按所选产品、风格生成手办效果图~'
detail.tasks.forEach((task) => {
const upload = uploadOf(detail, task.upload_id)
const photo = upload?.url || ''
const brought = isBroughtTask(detail, task)
const time = formatClock(task.completed_at || task.created_at)
const seconds =
task.duration > 0
? task.duration
: task.completed_at > task.created_at
? task.completed_at - task.created_at
: 0
const images = task.images.filter((image) => image.url)
const resultImages =
images.length > 0
? images
: brought && photo
? [{ image_id: '', path: '', url: photo, index: 0 }]
: []
if (!resultImages.length && task.status !== 'success') return
// 右侧气泡:purpose=origin 显示原图;external_effect 只展示提示词
const userImage = upload?.purpose === 'origin' && photo ? photo : undefined
const userText =
task.prompt ||
(brought || upload?.purpose === 'external_effect'
? '这是我在别的平台生成的卡通效果图,不用再调整了,直接帮我下单吧~'
: defaultPrompt)
history.push({
role: 'user',
text: userText,
image: userImage,
time: time || undefined,
taskId: task.task_id,
uploadId: task.upload_id,
})
resultImages.forEach((image) => {
history.push({
role: 'agent',
type: 'result',
image: image.url,
time: time || undefined,
durationSec: seconds,
meta: time
? `${time}${seconds > 0 ? ` · 生图用时 ${seconds}s` : ''}`
: seconds > 0
? `生图用时 ${seconds}s`
: '',
taskId: task.task_id,
imageId: image.image_id || undefined,
uploadId: task.upload_id,
prompt: task.prompt || undefined,
})
})
})
return history
}
/** 兼容旧调用:解析 detail 并产出 history */
export function parseSessionDetail(res: any): SessionDetail & {
sessionId: string
history: SessionHistoryItem[]
raw: any
} {
const detail = normalizeSessionDetail(res)
return {
...detail,
sessionId: detail.session_id,
history: sessionDetailToHistory(detail),
raw: res,
}
}
/** 将接口 body 归一成对象(后端若返回 text/plain,axios 会得到 JSON 字符串) */
function coerceResponseBody(res: unknown): Record<string, any> {
if (res == null) return {}
if (typeof res === 'string') {
const text = res.replace(/^\uFEFF/, '').trim()
if (!text) return {}
try {
const parsed = JSON.parse(text)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: {}
} catch {
const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start >= 0 && end > start) {
try {
const parsed = JSON.parse(text.slice(start, end + 1))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: {}
} catch {
return {}
}
}
return {}
}
}
if (typeof res === 'object' && !Array.isArray(res)) return res as Record<string, any>
return {}
}
/** 从 upload/url 业务体提取票据(兼容整包 / 已解包 / JSON 字符串) */
export function parseUploadUrlResult(res: any): UploadTicket {
const root = coerceResponseBody(res)
// 优先整包 { code, data: { upload_id, upload_url } }
const fromEnvelope =
root.data && typeof root.data === 'object' && !Array.isArray(root.data)
? (root.data as Record<string, any>)
: null
const data =
fromEnvelope && (fromEnvelope.upload_id || fromEnvelope.uploadId || fromEnvelope.upload_url)
? fromEnvelope
: root
return {
upload_id: textOf(data.upload_id || data.uploadId),
upload_url: normalizeOssUrl(
textOf(data.upload_url || data.uploadUrl || data.put_url || data.putUrl),
),
file_url: normalizeOssUrl(textOf(data.file_url || data.fileUrl)),
content_type: textOf(data.content_type || data.contentType) || 'image/jpeg',
expire_at: asCount(data.expire_at || data.expireAt),
}
}
function normalizeImageGenStatus(data: unknown, taskId: string): ImageGenStatus {
const record = asRecord(data) || {}
const images = listOf(record.images ?? record.image_list ?? record.results)
.map((item) => normalizeDetailImage(item))
.filter((item): item is SessionDetailImage => Boolean(item))
.map((item) => ({
image_id: item.image_id,
path: item.path,
url: item.url,
index: item.index,
}))
if (!images.length) {
const url = imageUrlOf(record)
if (url) {
images.push({
image_id: textOf(record.image_id || record.id),
path: textOf(record.path),
url,
index: 0,
})
}
}
const statusText = textOf(record.status).toLowerCase()
const status: ImageGenStatusCode =
statusText === 'pending' ||
statusText === 'processing' ||
statusText === 'success' ||
statusText === 'failed'
? statusText
: images.length
? 'success'
: 'pending'
return {
task_id: textOf(record.task_id) || taskId,
status,
error: textOf(record.error),
style: textOf(record.style),
prompt: textOf(record.prompt),
images,
}
}
/** 兼容旧 parseImageGenStatus 调用 */
export function parseImageGenStatus(res: any, taskId = '') {
const normalized = normalizeImageGenStatus(res, taskId)
const first = normalized.images[0]
return {
taskId: normalized.task_id,
status: normalized.status,
done: normalized.status === 'success' || Boolean(first?.url),
failed: normalized.status === 'failed',
resultUrl: first?.url || '',
imageId: first?.image_id || '',
images: normalized.images,
error: normalized.error,
raw: res,
}
}
function normalizeEffectSubmit(data: unknown): EffectSubmitResult {
const record = asRecord(data) || {}
const images = listOf(record.images)
.map((item) => normalizeDetailImage(item))
.filter((item): item is SessionDetailImage => Boolean(item))
.map((item) => ({
image_id: item.image_id,
path: item.path,
url: item.url,
index: item.index,
}))
const statusText = textOf(record.status)
const status: ImageGenStatusCode =
statusText === 'pending' ||
statusText === 'processing' ||
statusText === 'success' ||
statusText === 'failed'
? statusText
: 'success'
return {
task_id: textOf(record.task_id),
status,
error: textOf(record.error),
image_id: textOf(record.image_id) || images[0]?.image_id || '',
url:
normalizeOssUrl(textOf(record.url || record.file_url || record.effect_url)) ||
images[0]?.url ||
'',
images,
}
}
// ---------- 接口(参数对齐 agent-api,路径用 /jd) ----------
/** GET /jd/session/detail?session_id= */
export const getSessionDetail = (params: { session_id: string } | Record<string, any>) => {
const sessionId =
typeof params === 'object' && params
? textOf((params as any).session_id).trim()
: ''
return request(`${PREFIX}/session/detail`, {
method: 'GET',
params: {
...params,
session_id: sessionId || textOf((params as any)?.session_id),
},
}).then((res) => normalizeSessionDetail(res))
}
/** GET /jd/session/images */
export const getSessionImages = (query: {
shop_id?: number
session_id?: string
order?: 'asc' | 'desc'
page?: number
size?: number
} = {}) => {
const params: Record<string, any> = {
page: query.page && query.page > 0 ? query.page : 1,
size: Math.min(100, Math.max(1, query.size || 20)),
}
if (query.shop_id) params.shop_id = query.shop_id
if (query.session_id?.trim()) params.session_id = query.session_id.trim()
if (query.order === 'asc' || query.order === 'desc') params.order = query.order
return request(`${PREFIX}/session/images`, {
method: 'GET',
params,
})
}
/**
* POST /jd/upload/url
* 使用独立 axios(不走全局 response 拦截器),直接读 response.data
*/
export const getUploadUrl = async (input: {
session_id: string
filename?: string
content_type?: string
purpose?: UploadPurpose
}): Promise<UploadTicket> => {
const response = await axios.post(
buildApiUrl(`${PREFIX}/upload/url`),
{
session_id: input.session_id,
filename: input.filename || '',
content_type: input.content_type || 'image/jpeg',
purpose: input.purpose || 'origin',
},
{
headers: buildAuthHeaders(),
timeout: 30000,
},
)
// 注意:若 Content-Type 不是 application/json,axios 会把 body 留成 string
const ticket = parseUploadUrlResult(response?.data)
if (!ticket.upload_id) {
throw new Error('签发上传地址失败:缺少 upload_id')
}
if (!ticket.upload_url) {
throw new Error('签发上传地址失败:缺少 upload_url')
}
return ticket
}
/** PUT OSS */
export async function putUpload(uploadUrl: string, file: Blob, contentType: string) {
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: contentType ? { 'Content-Type': contentType } : undefined,
body: file,
})
if (!response.ok) throw new Error('图片上传失败')
}
/** POST /jd/upload/confirm */
export const confirmUpload = (input: { session_id: string; upload_id: string }) => {
return request(`${PREFIX}/upload/confirm`, {
method: 'POST',
data: {
session_id: input.session_id,
upload_id: input.upload_id,
},
})
}
/**
* 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 */
export const submitImageGen = (input: ImageGenSubmitInput) => {
const data: Record<string, string> = {
session_id: input.session_id,
prompt: input.prompt || '',
scene_prop: input.scene_prop || '',
custom_text: input.custom_text || '',
mode: input.mode || 'generate',
}
const uploadId = String(input.upload_id || '').trim()
const imageId = String(input.image_id || '').trim()
// 导入生图:upload_id;编辑效果图:image_id(互斥)
if (uploadId) data.upload_id = uploadId
if (imageId) data.image_id = imageId
return request(`${PREFIX}/imagegen/submit`, {
method: 'POST',
data,
}) as Promise<ImageGenTask>
}
/** POST /jd/effect/submit */
export const submitExternalEffect = (input: EffectSubmitInput) => {
return request(`${PREFIX}/effect/submit`, {
method: 'POST',
data: {
session_id: input.session_id,
upload_id: input.upload_id,
product_id: input.product_id,
product_name: input.product_name || '',
user_message: input.user_message || '',
mounts_id: input.mounts_id ?? 0,
scene_prop: input.scene_prop || '',
custom_text: input.custom_text || '',
base_type: input.base_type ?? 0,
frame_type: input.frame_type ?? 0,
mount_id: input.mount_id ?? 0,
pid: input.pid ?? 0,
proportion_id: input.proportion_id ?? 0,
pose_id: input.pose_id ?? 0,
style_id: input.style_id ?? 0,
prompt: input.prompt || '',
},
}).then(normalizeEffectSubmit)
}
/** GET /jd/imagegen/status?task_id= */
export const getImageGenStatus = (taskId: string) => {
return request(`${PREFIX}/imagegen/status`, {
method: 'GET',
params: { task_id: taskId },
}).then((data) => normalizeImageGenStatus(data, taskId))
}
/** POST /jd/order/confirm */
export const confirmOrder = (input: OrderConfirmInput) => {
const shapeId = input.shape_id ?? 0
return request(`${PREFIX}/order/confirm`, {
method: 'POST',
data: {
...input,
top_prod_id: input.top_prod_id ?? shapeId ?? 0,
shape_id: shapeId,
font_id: input.font_id ?? 0,
custom_text: input.custom_text || '',
back_text: input.back_text || '',
up_text: input.up_text || '',
down_text: input.down_text || '',
check_id: input.check_id ?? 0,
country_id: input.country_id ?? 45,
province_id: input.province_id || '',
city_id: input.city_id || '',
county_id: input.county_id || '',
},
}) as Promise<OrderConfirmResult>
}
/**
* 导入阶段:签发 URL → PUT OSS(不 confirm)
* 拿到 upload_id 后可立刻调 subject/classify
*/
export async function prepareUpload(
file: File | Blob,
options: {
session_id: string
filename?: string
purpose?: UploadPurpose
},
) {
const name = options.filename || guessFilename(file as File)
const contentType = guessContentType(file, name)
const ticket = await getUploadUrl({
session_id: options.session_id,
filename: name,
content_type: contentType,
purpose: options.purpose || 'origin',
})
await putUpload(ticket.upload_url, file, ticket.content_type || contentType)
return {
uploadId: ticket.upload_id,
fileUrl: ticket.file_url,
contentType: ticket.content_type || contentType,
filename: name,
}
}
/**
* 完整上传:签发 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
}