feat: 完整的生成流程 - 登录检查、文件上传、支付凭证验证

主要功能:
1.  添加 canGenerate() - 检查登录、metered 订阅、余额
2.  添加 uploadBlobFiles() - 自动上传所有 blob URL 到服务器
3.  支付凭证强制验证 - identifier 缺失时报错"支付失败"
4.  新增 lib/api/upload.ts - 文件上传 API
5.  更新 useTemplateRun hook 支持 identifier 参数

完整流程(7步骤):
  1. 表单验证
  2. canGenerate 检查(登录 + 订阅 + 余额)
  3. 上传 blob 文件到服务器
  4. recordTokenUsage 扣费并获取 identifier
  5. 验证所有 URL 都是服务器 URL
  6. 转换数据格式
  7. runTemplate(id, data, identifier) 创建任务

安全保障:
-  无 identifier → 抛出错误"支付凭证缺失"
-  blob URL 未上传 → 报错"存在未上传的文件"
-  余额不足 → 引导用户充值

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-11-11 18:51:31 +08:00
parent 74ca367081
commit 26cd0139bf
4 changed files with 228 additions and 25 deletions

View File

@@ -1,19 +1,14 @@
import { apiClient } from './client';
import { RunTemplateResponse, TemplateGenerationResponse, RunTemplateData } from '../types/template-run';
import { storage } from '../storage';
export async function runTemplate(templateId: string, data: RunTemplateData): Promise<RunTemplateResponse> {
// 从 storage 获取用户 session 作为 identifier
const sessionStr = await storage.getItem('session');
let identifier = 'anonymous';
if (sessionStr) {
try {
const session = JSON.parse(sessionStr);
identifier = session?.user?.id || 'anonymous';
} catch (error) {
console.warn('Failed to parse session:', error);
}
export async function runTemplate(
templateId: string,
data: RunTemplateData,
identifier: string
): Promise<RunTemplateResponse> {
// identifier 是必需的支付凭证,如果没有提供说明支付失败
if (!identifier) {
throw new Error('支付凭证缺失,无法创建生成任务');
}
return apiClient<RunTemplateResponse>(`/api/templates/${templateId}/run`, {

88
lib/api/upload.ts Normal file
View File

@@ -0,0 +1,88 @@
import { apiClient } from './client';
export interface UploadResponse {
success: boolean;
data?: {
url: string;
filename: string;
size: number;
mimeType: string;
};
message?: string;
}
/**
* 上传文件到服务器
* @param uri 本地文件 URI可以是 blob: 或 file:// 格式)
* @param type 文件类型image 或 video
*/
export async function uploadFile(uri: string, type: 'image' | 'video'): Promise<UploadResponse> {
try {
// 如果已经是 https URL直接返回
if (uri.startsWith('https://') || uri.startsWith('http://')) {
return {
success: true,
data: {
url: uri,
filename: uri.split('/').pop() || 'file',
size: 0,
mimeType: type === 'image' ? 'image/jpeg' : 'video/mp4',
},
};
}
// 创建 FormData
const formData = new FormData();
// 从 URI 中提取文件信息
const filename = uri.split('/').pop() || `${type}_${Date.now()}`;
const match = /\.(\w+)$/.exec(filename);
const fileType = match ? match[1] : (type === 'image' ? 'jpg' : 'mp4');
// 添加文件到 FormData
formData.append('file', {
uri,
type: type === 'image' ? `image/${fileType}` : `video/${fileType}`,
name: filename,
} as any);
formData.append('type', type);
// 上传文件
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL || 'https://api.mixvideo.bowong.cc'}/api/upload`, {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Upload failed');
}
return result;
} catch (error) {
console.error('Failed to upload file:', error);
return {
success: false,
message: error instanceof Error ? error.message : '上传失败,请稍后重试',
};
}
}
/**
* 批量上传文件
*/
export async function uploadFiles(
files: Array<{ uri: string; type: 'image' | 'video' }>
): Promise<UploadResponse[]> {
const uploadPromises = files.map(file => uploadFile(file.uri, file.type));
return Promise.all(uploadPromises);
}