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

@@ -20,9 +20,11 @@ export const unstable_settings = {
import { BackButton } from '@/components/ui/back-button';
import { DynamicFormField } from '@/components/forms/dynamic-form-field';
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 { uploadFile } from '@/lib/api/upload';
import { Template, TemplateGraphNode } from '@/lib/types/template';
import { useAuth } from '@/hooks/use-auth';
const FALLBACK_PREVIEW =
'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() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { session, isLoading: authLoading } = useAuth();
const [template, setTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(true);
@@ -95,6 +98,73 @@ export default function TemplateFormScreen() {
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> => {
if (!template?.formSchema?.startNodes) return {};
@@ -161,7 +231,29 @@ export default function TemplateFormScreen() {
setIsSubmitting(true);
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 usageResponse = await recordTokenUsage({
price: requiredAmount,
@@ -172,16 +264,28 @@ export default function TemplateFormScreen() {
},
});
if (!usageResponse.success) {
// recordTokenUsage 已经显示了错误提示,这里不需要重复
if (!usageResponse.success || !usageResponse.data?.identifier) {
Alert.alert('扣费失败', usageResponse.message || '请稍后重试');
return;
}
// 2. 转换表单数据为正确的 API 格式
const transformedData = transformFormDataToRunFormat(formData);
const paymentIdentifier = usageResponse.data.identifier;
// 3. 调用 template run
const runResponse = await runTemplate(id, transformedData);
// 步骤 4: 验证所有 URL 都是有效的服务器 URL
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) {
Alert.alert('错误', '生成任务创建失败');
@@ -190,7 +294,7 @@ export default function TemplateFormScreen() {
const generationId = runResponse.data;
// 4. 跳转到结果页面
// 步骤 7: 跳转到结果页面
Alert.alert('成功', '视频生成任务已创建', [
{
text: '查看结果',