From 6f607f8fcc6e91b78cc9ec35c311d25451585e6f Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:05:26 +0800 Subject: [PATCH] feat(media): add validated upload adapter --- "_PR\350\257\264\346\230\216.md" | 33 ++++ frontend/src/entities/index.ts | 5 +- frontend/src/entities/media/README.md | 37 +++++ frontend/src/entities/media/api.test.ts | 190 ++++++++++++++++++++++++ frontend/src/entities/media/api.ts | 81 ++++++++++ frontend/src/entities/media/index.ts | 14 ++ frontend/src/shared/api/upload.ts | 114 ++++++++++++++ 7 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 "_PR\350\257\264\346\230\216.md" create mode 100644 frontend/src/entities/media/README.md create mode 100644 frontend/src/entities/media/api.test.ts create mode 100644 frontend/src/entities/media/api.ts create mode 100644 frontend/src/shared/api/upload.ts diff --git "a/_PR\350\257\264\346\230\216.md" "b/_PR\350\257\264\346\230\216.md" new file mode 100644 index 0000000..4abc77c --- /dev/null +++ "b/_PR\350\257\264\346\230\216.md" @@ -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 +``` diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359d..a8dde27 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -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' diff --git a/frontend/src/entities/media/README.md b/frontend/src/entities/media/README.md new file mode 100644 index 0000000..e28ac88 --- /dev/null +++ b/frontend/src/entities/media/README.md @@ -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 +``` diff --git a/frontend/src/entities/media/api.test.ts b/frontend/src/entities/media/api.test.ts new file mode 100644 index 0000000..f879e9a --- /dev/null +++ b/frontend/src/entities/media/api.test.ts @@ -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' }, + }) +} diff --git a/frontend/src/entities/media/api.ts b/frontend/src/entities/media/api.ts new file mode 100644 index 0000000..14cf3d6 --- /dev/null +++ b/frontend/src/entities/media/api.ts @@ -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 { + // 与后端的 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(`/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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts index 347e626..ce7747f 100644 --- a/frontend/src/entities/media/index.ts +++ b/frontend/src/entities/media/index.ts @@ -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 +} + +// 上层只能通过 @/entities 公共入口取得真实适配器,避免页面深度导入内部文件。 +export { createMediaApis, MediaContractError } from './api' diff --git a/frontend/src/shared/api/upload.ts b/frontend/src/shared/api/upload.ts new file mode 100644 index 0000000..314ad67 --- /dev/null +++ b/frontend/src/shared/api/upload.ts @@ -0,0 +1,114 @@ +interface ApiEnvelope { + code: number + message: string + data: unknown +} + +/** + * 上传请求已经到达 HTTP/业务协议边界,但服务端没有返回可供业务层使用的数据。 + * status 是 HTTP 状态码,code 是 Windup 响应体中的业务码;两者不能混为一谈, + * 因为后端的业务异常也会以 HTTP 200 返回。 + */ +export class UploadRequestError extends Error { + readonly status: number + readonly code: number + + constructor(status: number, code: number, message: string) { + super(message) + this.name = 'UploadRequestError' + this.status = status + this.code = code + } +} + +/** + * 上传边界没有拿到后端地址。生产环境绝不能默默退回 127.0.0.1, + * 因为那个地址指向访问者自己的电脑,而不是 Windup 服务。 + */ +export class UploadConfigurationError extends Error { + constructor() { + super('媒体上传不可用:请配置 VITE_API_BASE_URL') + this.name = 'UploadConfigurationError' + } +} + +/** + * 发送 multipart/form-data,并解开后端统一的 { code, message, data } 响应。 + * 不手动设置 Content-Type:浏览器需要根据当前 FormData 自动补上 boundary。 + * fetch 自身抛出的网络错误和 AbortError 保持原样,调用方可据此区分取消与失败。 + */ +export async function upload( + path: string, + formData: FormData, + signal?: AbortSignal, +): Promise { + const response = await fetch(joinUrl(requireApiBaseUrl(), path), { + method: 'POST', + body: formData, + signal, + }) + const body = await readResponseBody(response) + + if (!isApiEnvelope(body)) { + throw new UploadRequestError( + response.status, + response.status, + '上传响应格式错误,缺少有效的 code、message 或 data 字段', + ) + } + + if (!response.ok || body.code !== 200) { + throw new UploadRequestError(response.status, body.code, body.message || '上传失败') + } + + if (body.data === null || body.data === undefined) { + throw new UploadRequestError(response.status, body.code, '上传成功响应未返回 data') + } + + return body.data as T +} + +function requireApiBaseUrl(): string { + const value = import.meta.env.VITE_API_BASE_URL + if (typeof value !== 'string' || value.trim() === '') { + throw new UploadConfigurationError() + } + return value +} + +async function readResponseBody(response: Response): Promise { + try { + return await response.json() + } catch { + if (!response.ok) { + throw new UploadRequestError( + response.status, + response.status, + response.statusText || `上传请求失败(HTTP ${response.status})`, + ) + } + throw new UploadRequestError( + response.status, + response.status, + '上传响应格式错误,无法解析 JSON', + ) + } +} + +function isApiEnvelope(value: unknown): value is ApiEnvelope { + return ( + isRecord(value) && + typeof value.code === 'number' && + Number.isFinite(value.code) && + typeof value.message === 'string' && + Object.hasOwn(value, 'data') + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function joinUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}` +}