Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions _PR说明.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Media Upload 前端适配模块

Refs #109

## 做了什么

- 为 Quick Start / Generation 提供真实的参考图片上传适配器。
- 按后端 `POST /media/upload` 契约提交 `file` 表单文件,并通过查询参数传递 `category`。
- 区分 HTTP 状态码和后端业务码,保留后端错误信息,不把失败降级为假成功。
- 对成功响应执行运行时校验,确认完整媒体元数据后才返回 `MediaReference`。
- 支持 `AbortSignal`,调用方可取消仍在途的上传。
- 通过 `@/entities` 公共入口暴露适配器;后端地址缺失时明确失败,不误发到访问者本机。

## 改动边界

- `frontend/src/entities/media/**`
- `frontend/src/shared/api/upload.ts`
- `_PR说明.md`

没有修改页面、WorkflowRun、Controller、App 或后端,也没有复制后端实现。

## 验收

在 `frontend` 目录依次运行:

```powershell
npm.cmd run format:check
npm.cmd run lint
npm.cmd run typecheck
npm.cmd test -- src/entities/media/api.test.ts
npm.cmd test
npm.cmd run build
```
5 changes: 3 additions & 2 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export type {
TaskStatus,
} from './generation'

/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */
export type { MediaReference } from './media'
/* 媒体上传 —— 页面只依赖公开工厂与不透明引用,不处理 multipart 协议。 */
export { createMediaApis, MediaContractError } from './media'
export type { MediaApis, MediaCategory, MediaReference } from './media'

/* 工作流 —— 节点与运行状态都由前端管理 */
export { WORKFLOW_STEP_ORDER } from './workflow-run'
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/entities/media/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Media Upload

这个目录提供 Quick Start 和 Generation 上传参考图片时共用的最小适配能力,不包含上传按钮或页面状态。

## 用户点击上传后发生什么

页面把用户选中的 `File` 交给 `createMediaApis().upload(...)`。适配器先确认浏览器报告的 MIME 类型是 `image/*`,再把原文件装进 `FormData`,把用途分类放进 `category` 查询参数,通过 `POST /media/upload?category=...` 发送给后端。这一位置来自当前 `main` 的 FastAPI 路由声明:`file` 是表单文件,`category` 是查询参数。

后端会再次校验图片类型,并把文件写入已配置的对象存储。只有后端返回业务码 `200`,且 `url`、对象 key、文件名、图片 MIME 类型和文件大小都符合契约时,前端才把 `url` 作为 `MediaReference` 交给 Quick Start 或 Generation。业务失败、HTTP 失败、非法 JSON 和缺字段响应都会抛出错误,不会伪造成功。

调用方可以传入 `AbortSignal`。用户取消、离开页面或用新文件替换旧文件时,可终止仍在途的上传;浏览器的 `AbortError` 会原样返回,便于页面单独处理“取消”状态。

## 文件范围

- 接受范围与当前后端一致:MIME 类型为 `image/*` 的文件。
- 当前契约没有声明扩展名白名单或大小上限,前端不擅自增加限制。
- 默认分类是 `general`;参考图应传 `reference-image`。另有 `outfit-preview` 和 `action-frame`,与后端枚举一致。
- 本模块不负责裁剪、压缩、预览、重试、进度显示或持久化,也不直接调用生成接口。

## 依赖

- 浏览器原生 `File`、`FormData`、`fetch` 和 `AbortSignal`。
- 必须配置 `VITE_API_BASE_URL`。缺失时上传会明确报错,不会退回访问者本机的 `127.0.0.1`。
- 后端 `POST /media/upload` 以及后端配置的对象存储。没有可用后端或对象存储时,上传应真实失败。

## 验收命令

在 `frontend` 目录执行:

```powershell
npm.cmd run format:check
npm.cmd run lint
npm.cmd run typecheck
npm.cmd test -- src/entities/media/api.test.ts
npm.cmd test
npm.cmd run build
```
190 changes: 190 additions & 0 deletions frontend/src/entities/media/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { createMediaApis } from '@/entities'

afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})

describe('MediaApis.upload', () => {
it('把图片和默认查询分类交给后端,并返回经过校验的媒体引用', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
url: 'https://cdn.example.com/media/reference.png',
object_key: 'media/general/reference.png',
filename: 'reference.png',
content_type: 'image/png',
size: 4,
}),
)
vi.stubGlobal('fetch', fetchMock)
const file = imageFile()

const result = await createMediaApis().upload(file)

expect(result).toBe('https://cdn.example.com/media/reference.png')
expect(fetchMock).toHaveBeenCalledTimes(1)

const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://127.0.0.1:8000/media/upload?category=general')
expect(init.method).toBe('POST')
expect(new Headers(init.headers).has('Content-Type')).toBe(false)

const body = init.body as FormData
expect(body.get('file')).toBe(file)
expect(body.has('category')).toBe(false)
})

it('传递调用方选择的图片用途和取消信号', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
url: 'https://cdn.example.com/media/reference.png',
object_key: 'media/reference-image/reference.png',
filename: 'reference.png',
content_type: 'image/png',
size: 4,
}),
)
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()

await createMediaApis().upload(imageFile(), 'reference-image', controller.signal)

const init = fetchMock.mock.calls[0]?.[1] as RequestInit
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:8000/media/upload?category=reference-image',
)
expect((init.body as FormData).has('category')).toBe(false)
expect(init.signal).toBe(controller.signal)
})

it('在请求发出前拒绝非图片文件', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const file = new File(['text'], 'notes.txt', { type: 'text/plain' })

await expect(createMediaApis().upload(file)).rejects.toThrow('仅支持图片文件')
expect(fetchMock).not.toHaveBeenCalled()
})

it('后端地址未配置时明确失败,不把文件发送到访问者本机', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
vi.stubEnv('VITE_API_BASE_URL', '')

await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
name: 'UploadConfigurationError',
message: '媒体上传不可用:请配置 VITE_API_BASE_URL',
})
expect(fetchMock).not.toHaveBeenCalled()
})

it('把 HTTP 200 中的后端业务失败作为真实错误抛出', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 400, message: '仅支持图片文件', data: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
)

await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
name: 'UploadRequestError',
status: 200,
code: 400,
message: '仅支持图片文件',
})
})

it('保留非成功 HTTP 响应的状态和后端错误信息', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 503, message: '对象存储暂不可用', data: null }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
}),
),
)

await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
name: 'UploadRequestError',
status: 503,
code: 503,
message: '对象存储暂不可用',
})
})

it('拒绝无法解析的 JSON 响应', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('not json', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
),
)

await expect(createMediaApis().upload(imageFile())).rejects.toThrow(
'上传响应格式错误,无法解析 JSON',
)
})

it.each([
['url 为空', { url: '' }],
['object_key 缺失', { object_key: undefined }],
['content_type 不是图片', { content_type: 'text/plain' }],
['size 不是非负整数', { size: -1 }],
])('拒绝不符合后端契约的成功数据:%s', async (_caseName, override) => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse({
url: 'https://cdn.example.com/media/reference.png',
object_key: 'media/general/reference.png',
filename: 'reference.png',
content_type: 'image/png',
size: 4,
...override,
}),
),
)

await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({
name: 'MediaContractError',
})
})

it('不包装浏览器抛出的取消错误', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000')
const abortError = new DOMException('This operation was aborted', 'AbortError')
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError))
const controller = new AbortController()
controller.abort()

await expect(
createMediaApis().upload(imageFile(), 'reference-image', controller.signal),
).rejects.toBe(abortError)
})
})

function imageFile(): File {
return new File(['wind'], 'reference.png', { type: 'image/png' })
}

function jsonResponse(data: unknown): Response {
return new Response(JSON.stringify({ code: 200, message: 'success', data }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
81 changes: 81 additions & 0 deletions frontend/src/entities/media/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { upload as uploadRequest } from '@/shared/api/upload'

import type { MediaApis, MediaCategory, MediaReference } from '.'

/** 后端声称上传成功、但返回数据不符合 /media/upload 契约。 */
export class MediaContractError extends Error {
constructor(message: string) {
super(`媒体上传响应格式错误:${message}`)
this.name = 'MediaContractError'
}
}

/** /media/upload 成功时 data 字段的后端原始形状。 */
interface BackendMediaUpload {
url: string
object_key: string
filename: string
content_type: string
size: number
}

/**
* 创建真实媒体上传适配器。这里不缓存文件、不生成本地假 URL,也不吞掉错误;
* 只有服务端确认成功且完整响应通过运行时校验后,才交付 MediaReference。
*/
export function createMediaApis(): MediaApis {
return {
async upload(
file: File,
category: MediaCategory = 'general',
signal?: AbortSignal,
): Promise<MediaReference> {
// 与后端的 image/* 规则一致,尽早反馈可避免上传无效文件;后端仍是最终校验者。
if (!file.type.startsWith('image/')) {
throw new TypeError('仅支持图片文件')
}

const formData = new FormData()
formData.append('file', file)

// main 的 FastAPI 路由只把 file 声明为 File;category 未声明 Form,因此属于查询参数。
const query = new URLSearchParams({ category })
const result = await uploadRequest<unknown>(`/media/upload?${query}`, formData, signal)
return parseMediaReference(result)
},
}
}

function parseMediaReference(value: unknown): MediaReference {
assertBackendMediaUpload(value)

// MediaReference 是不透明引用;当前后端明确约定用已校验的 url 回填业务数据。
return value.url as MediaReference
}

function assertBackendMediaUpload(value: unknown): asserts value is BackendMediaUpload {
if (!isRecord(value)) {
throw new MediaContractError('data 必须是对象')
}

assertNonEmptyString(value.url, 'url')
assertNonEmptyString(value.object_key, 'object_key')
assertNonEmptyString(value.filename, 'filename')

if (typeof value.content_type !== 'string' || !value.content_type.startsWith('image/')) {
throw new MediaContractError('content_type 必须是 image/*')
}
if (typeof value.size !== 'number' || !Number.isInteger(value.size) || value.size < 0) {
throw new MediaContractError('size 必须是非负整数')
}
}

function assertNonEmptyString(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || value.trim() === '') {
throw new MediaContractError(`${field} 必须是非空字符串`)
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
14 changes: 14 additions & 0 deletions frontend/src/entities/media/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ declare const mediaReferenceBrand: unique symbol
export type MediaReference = string & {
readonly [mediaReferenceBrand]: 'MediaReference'
}

/** 上传媒体时的业务用途;值与后端 MediaCategory 枚举逐项对应。 */
export type MediaCategory = 'reference-image' | 'outfit-preview' | 'action-frame' | 'general'

/**
* 媒体实体对页面和生成流程暴露的最小能力。
* signal 用于页面离开、用户取消或新上传替换旧上传时终止仍在途的请求。
*/
export interface MediaApis {
upload(file: File, category?: MediaCategory, signal?: AbortSignal): Promise<MediaReference>
}

// 上层只能通过 @/entities 公共入口取得真实适配器,避免页面深度导入内部文件。
export { createMediaApis, MediaContractError } from './api'
Loading