✨ 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:
@@ -20,9 +20,11 @@ export const unstable_settings = {
|
|||||||
import { BackButton } from '@/components/ui/back-button';
|
import { BackButton } from '@/components/ui/back-button';
|
||||||
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
|
||||||
import { getTemplateById } from '@/lib/api/templates';
|
import { getTemplateById } from '@/lib/api/templates';
|
||||||
import { recordTokenUsage } from '@/lib/api/balance';
|
import { recordTokenUsage, getUserBalance } from '@/lib/api/balance';
|
||||||
import { runTemplate } from '@/lib/api/template-runs';
|
import { runTemplate } from '@/lib/api/template-runs';
|
||||||
|
import { uploadFile } from '@/lib/api/upload';
|
||||||
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
import { Template, TemplateGraphNode } from '@/lib/types/template';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
const FALLBACK_PREVIEW =
|
const FALLBACK_PREVIEW =
|
||||||
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
|
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
|
||||||
@@ -32,6 +34,7 @@ const FALLBACK_INSET =
|
|||||||
export default function TemplateFormScreen() {
|
export default function TemplateFormScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { session, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [template, setTemplate] = useState<Template | null>(null);
|
const [template, setTemplate] = useState<Template | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -95,6 +98,73 @@ export default function TemplateFormScreen() {
|
|||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否可以生成(登录 + metered 订阅 + 余额充足)
|
||||||
|
*/
|
||||||
|
const canGenerate = async (): Promise<{ canProceed: boolean; message?: string }> => {
|
||||||
|
// 1. 检查是否登录
|
||||||
|
if (!session?.user) {
|
||||||
|
return {
|
||||||
|
canProceed: false,
|
||||||
|
message: '请先登录',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查是否有 metered 订阅
|
||||||
|
const balanceResponse = await getUserBalance();
|
||||||
|
if (!balanceResponse.success) {
|
||||||
|
return {
|
||||||
|
canProceed: false,
|
||||||
|
message: balanceResponse.message || '未找到计费订阅,请先订阅',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查余额是否充足
|
||||||
|
const requiredAmount = template?.costPrice || 0;
|
||||||
|
const currentBalance = balanceResponse.data.remainingTokenBalance;
|
||||||
|
|
||||||
|
if (currentBalance < requiredAmount) {
|
||||||
|
return {
|
||||||
|
canProceed: false,
|
||||||
|
message: `余额不足\n当前余额: ${currentBalance}\n需要费用: ${requiredAmount}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canProceed: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传所有 blob URL 文件到服务器
|
||||||
|
*/
|
||||||
|
const uploadBlobFiles = async (data: Record<string, any>): Promise<Record<string, any>> => {
|
||||||
|
if (!template?.formSchema?.startNodes) return data;
|
||||||
|
|
||||||
|
const uploadedData = { ...data };
|
||||||
|
|
||||||
|
for (const node of template.formSchema.startNodes) {
|
||||||
|
const value = data[node.id];
|
||||||
|
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
// 检查是否是 blob URL
|
||||||
|
if (typeof value === 'string' && value.startsWith('blob:')) {
|
||||||
|
const fileType = node.type === 'image' ? 'image' : node.type === 'video' ? 'video' : null;
|
||||||
|
|
||||||
|
if (fileType) {
|
||||||
|
const uploadResponse = await uploadFile(value, fileType as 'image' | 'video');
|
||||||
|
|
||||||
|
if (!uploadResponse.success || !uploadResponse.data) {
|
||||||
|
throw new Error(`上传 ${node.data.label} 失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedData[node.id] = uploadResponse.data.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadedData;
|
||||||
|
};
|
||||||
|
|
||||||
const transformFormDataToRunFormat = (formData: Record<string, any>): Record<string, any> => {
|
const transformFormDataToRunFormat = (formData: Record<string, any>): Record<string, any> => {
|
||||||
if (!template?.formSchema?.startNodes) return {};
|
if (!template?.formSchema?.startNodes) return {};
|
||||||
|
|
||||||
@@ -161,7 +231,29 @@ export default function TemplateFormScreen() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 扣费(已集成余额检查和错误处理)
|
// 步骤 1: 检查是否可以生成(登录 + 订阅 + 余额)
|
||||||
|
const checkResult = await canGenerate();
|
||||||
|
if (!checkResult.canProceed) {
|
||||||
|
Alert.alert('无法生成', checkResult.message || '请检查账户状态', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '去充值',
|
||||||
|
onPress: () => router.push('/exchange' as any),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 2: 上传所有 blob 文件到服务器
|
||||||
|
let uploadedFormData: Record<string, any>;
|
||||||
|
try {
|
||||||
|
uploadedFormData = await uploadBlobFiles(formData);
|
||||||
|
} catch (uploadError) {
|
||||||
|
Alert.alert('上传失败', uploadError instanceof Error ? uploadError.message : '文件上传失败,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 3: 扣费并获取 identifier
|
||||||
const requiredAmount = template.costPrice || 0;
|
const requiredAmount = template.costPrice || 0;
|
||||||
const usageResponse = await recordTokenUsage({
|
const usageResponse = await recordTokenUsage({
|
||||||
price: requiredAmount,
|
price: requiredAmount,
|
||||||
@@ -172,16 +264,28 @@ export default function TemplateFormScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!usageResponse.success) {
|
if (!usageResponse.success || !usageResponse.data?.identifier) {
|
||||||
// recordTokenUsage 已经显示了错误提示,这里不需要重复
|
Alert.alert('扣费失败', usageResponse.message || '请稍后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 转换表单数据为正确的 API 格式
|
const paymentIdentifier = usageResponse.data.identifier;
|
||||||
const transformedData = transformFormDataToRunFormat(formData);
|
|
||||||
|
|
||||||
// 3. 调用 template run
|
// 步骤 4: 验证所有 URL 都是有效的服务器 URL
|
||||||
const runResponse = await runTemplate(id, transformedData);
|
const hasInvalidUrl = Object.values(uploadedFormData).some(
|
||||||
|
(value) => typeof value === 'string' && value.startsWith('blob:')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasInvalidUrl) {
|
||||||
|
Alert.alert('错误', '存在未上传的文件,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 5: 转换表单数据为 API 格式
|
||||||
|
const transformedData = transformFormDataToRunFormat(uploadedFormData);
|
||||||
|
|
||||||
|
// 步骤 6: 调用 template run,使用支付凭证 identifier
|
||||||
|
const runResponse = await runTemplate(id, transformedData, paymentIdentifier);
|
||||||
|
|
||||||
if (!runResponse.success) {
|
if (!runResponse.success) {
|
||||||
Alert.alert('错误', '生成任务创建失败');
|
Alert.alert('错误', '生成任务创建失败');
|
||||||
@@ -190,7 +294,7 @@ export default function TemplateFormScreen() {
|
|||||||
|
|
||||||
const generationId = runResponse.data;
|
const generationId = runResponse.data;
|
||||||
|
|
||||||
// 4. 跳转到结果页面
|
// 步骤 7: 跳转到结果页面
|
||||||
Alert.alert('成功', '视频生成任务已创建', [
|
Alert.alert('成功', '视频生成任务已创建', [
|
||||||
{
|
{
|
||||||
text: '查看结果',
|
text: '查看结果',
|
||||||
|
|||||||
@@ -102,13 +102,29 @@ export function useTemplateRun(options: UseTemplateRunOptions = {}) {
|
|||||||
// 运行模板
|
// 运行模板
|
||||||
const executeTemplate = useCallback(async (
|
const executeTemplate = useCallback(async (
|
||||||
templateId: string,
|
templateId: string,
|
||||||
data: RunTemplateData
|
data: RunTemplateData,
|
||||||
|
identifier?: string
|
||||||
) => {
|
) => {
|
||||||
if (state === 'submitting' || state === 'running') {
|
if (state === 'submitting' || state === 'running') {
|
||||||
console.warn('模板正在运行中,请勿重复提交');
|
console.warn('模板正在运行中,请勿重复提交');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否提供了支付凭证
|
||||||
|
if (!identifier) {
|
||||||
|
const error = new Error('缺少支付凭证,请先完成支付流程');
|
||||||
|
setError(error);
|
||||||
|
setState('error');
|
||||||
|
setProgress({
|
||||||
|
status: 'failed',
|
||||||
|
progress: 0,
|
||||||
|
message: '支付凭证缺失',
|
||||||
|
});
|
||||||
|
options.onError?.(error);
|
||||||
|
Alert.alert('错误', '支付凭证缺失,无法创建生成任务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
setState('submitting');
|
setState('submitting');
|
||||||
@@ -120,8 +136,8 @@ export function useTemplateRun(options: UseTemplateRunOptions = {}) {
|
|||||||
message: '正在提交任务...',
|
message: '正在提交任务...',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 提交模板运行
|
// 提交模板运行,传递支付凭证
|
||||||
const runResponse = await runTemplate(templateId, data);
|
const runResponse = await runTemplate(templateId, data, identifier);
|
||||||
|
|
||||||
if (!runResponse.success || !runResponse.data) {
|
if (!runResponse.success || !runResponse.data) {
|
||||||
throw new Error('提交任务失败');
|
throw new Error('提交任务失败');
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import { RunTemplateResponse, TemplateGenerationResponse, RunTemplateData } from '../types/template-run';
|
import { RunTemplateResponse, TemplateGenerationResponse, RunTemplateData } from '../types/template-run';
|
||||||
import { storage } from '../storage';
|
|
||||||
|
|
||||||
export async function runTemplate(templateId: string, data: RunTemplateData): Promise<RunTemplateResponse> {
|
export async function runTemplate(
|
||||||
// 从 storage 获取用户 session 作为 identifier
|
templateId: string,
|
||||||
const sessionStr = await storage.getItem('session');
|
data: RunTemplateData,
|
||||||
let identifier = 'anonymous';
|
identifier: string
|
||||||
|
): Promise<RunTemplateResponse> {
|
||||||
if (sessionStr) {
|
// identifier 是必需的支付凭证,如果没有提供说明支付失败
|
||||||
try {
|
if (!identifier) {
|
||||||
const session = JSON.parse(sessionStr);
|
throw new Error('支付凭证缺失,无法创建生成任务');
|
||||||
identifier = session?.user?.id || 'anonymous';
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse session:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiClient<RunTemplateResponse>(`/api/templates/${templateId}/run`, {
|
return apiClient<RunTemplateResponse>(`/api/templates/${templateId}/run`, {
|
||||||
|
|||||||
88
lib/api/upload.ts
Normal file
88
lib/api/upload.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user