From 0d042edc73ff977f432d03c8849fb7fa1d4943b6 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 4 Sep 2026 18:16:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E9=94=AE=E6=88=90=E7=89=87=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/serializers.py | 2 +- .../apps/projects/services/quick_create.py | 63 +++++++++++++++++++ core/backend/apps/projects/views.py | 16 +++++ core/frontend/src/routes/quick-create.tsx | 50 +++++++++++++-- core/frontend/src/routes/route-config.ts | 5 +- core/frontend/src/types.ts | 1 + 6 files changed, 130 insertions(+), 7 deletions(-) diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index 0ff7d22..a32d32d 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -22,7 +22,7 @@ class ModelConfigSerializer(serializers.ModelSerializer): class Meta: model = ModelConfig - fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "metadata"] + fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "is_default", "metadata"] read_only_fields = fields diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py index 6b7a191..794df01 100644 --- a/core/backend/apps/projects/services/quick_create.py +++ b/core/backend/apps/projects/services/quick_create.py @@ -103,7 +103,70 @@ def _script_generation_inflight(project: Project) -> bool: ).exists() +def estimate_quick_create_points( + *, + team, + text_model: ModelConfig, + image_model: ModelConfig, + video_model: ModelConfig, + aspect_ratio: str, + resolution: str, + total_duration: int, +): + """与前端按钮预估同口径:脚本挂牌 + 默认出图×场次 + 视频挂牌秒价×时长(无引用视频)。""" + from decimal import Decimal + + from apps.billing.pricing import quote_flat, quote_video_estimate + + scene_count = max(1, int(total_duration) // 15) + script_points = quote_flat(text_model, units=1, team=team).points + image_points = quote_flat(image_model, units=scene_count, team=team).points + _tokens, video_quote = quote_video_estimate( + video_model, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=int(total_duration), + references=[], + team=team, + ) + return Decimal(script_points) + Decimal(image_points) + Decimal(video_quote.points) + + +def assert_quick_create_balance( + *, + team, + text_model: ModelConfig, + image_model: ModelConfig, + video_model: ModelConfig, + aspect_ratio: str, + resolution: str, + total_duration: int, +): + """提交前确认可用积分 ≥ 预估总额;不足则抛 ValueError(中文提示)。不预留,只门禁。""" + from decimal import Decimal + + from apps.billing.models import CreditAccount + + needed = estimate_quick_create_points( + team=team, + text_model=text_model, + image_model=image_model, + video_model=video_model, + aspect_ratio=aspect_ratio, + resolution=resolution, + total_duration=total_duration, + ) + account = CreditAccount.objects.filter(team=team).first() + available = (account.balance - account.reserved_balance) if account else Decimal("0") + if available < needed: + raise ValueError( + f"团队余额不足,预计需要 {needed} 积分,当前可用 {available} 积分,请充值后再开始" + ) + return needed, available + + def get_quick_script_model() -> ModelConfig | None: + """极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。""" return ( ModelConfig.objects.select_related("provider") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index aacedef..08d8fdd 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -589,6 +589,22 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): ) team = self.get_team() + # 开工前门禁:可用积分必须盖住「脚本+出图粗估+视频」挂牌合计(与按钮预估同口径)。 + # 不在这里预留整笔,后续各阶段各自 reserve;这里只避免积不够半路挂掉。 + from .services.quick_create import assert_quick_create_balance + + try: + assert_quick_create_balance( + team=team, + text_model=text_model, + image_model=image_model, + video_model=video_model, + aspect_ratio=aspect_ratio, + resolution=resolution, + total_duration=total_duration, + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) reused_assets = [] if source_product_id: try: diff --git a/core/frontend/src/routes/quick-create.tsx b/core/frontend/src/routes/quick-create.tsx index d9876ab..1c19bf6 100644 --- a/core/frontend/src/routes/quick-create.tsx +++ b/core/frontend/src/routes/quick-create.tsx @@ -29,6 +29,7 @@ import { estimateCost, FC_MODELS, modelLabel, + pointsPerCallFromCatalog, pointsPerImageFromCatalog, type BillingRates, } from "../components/free-create/constants"; @@ -60,6 +61,10 @@ const QUICK_RESOLUTIONS = [ { value: "4k", label: "4K 超清" }, ]; const QUICK_DURATIONS = [15, 30, 45, 60]; +/** 与后端 QUICK_SCRIPT_MODEL_NAME 一致 */ +const QUICK_SCRIPT_MODEL = "doubao-seed-2-1-pro-260628"; +const QUICK_SCRIPT_POINTS_FALLBACK = 100; +const QUICK_IMAGE_POINTS_FALLBACK = 20; function modelResolutions(config: ModelConfig | undefined) { const capabilities = (config?.metadata?.capabilities || {}) as Record; @@ -413,6 +418,24 @@ export function QuickCreatePage({ onNotify?.("info", "请选择商品品类,脚本会按品类来写"); return; } + // 与后端门禁同口径:可用积分不够预估总额就不提交。 + if (estimatedPoints > 0) { + try { + const summary = await api.billingSummary(); + const balance = Number(summary.account?.balance || 0); + const reserved = Number(summary.account?.reserved_balance || 0); + const available = balance - reserved; + if (available < estimatedPoints) { + onNotify?.( + "info", + `积分不足:预计需要 ${estimatedPoints} 积分,当前可用 ${Math.max(0, Math.floor(available))} 积分,请充值后再开始`, + ); + return; + } + } catch { + /* 余额接口失败时仍交给后端硬拦 */ + } + } setSubmitting(true); setJob(null); setServiceUnavailable(false); @@ -465,6 +488,8 @@ export function QuickCreatePage({ setServiceUnavailable(true); setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试"); onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试"); + } else if (error instanceof ApiError && error.status === 400 && /余额不足|积分不足/.test(error.message || "")) { + onNotify?.("info", error.message); } else { onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败"); } @@ -572,14 +597,31 @@ export function QuickCreatePage({ { ratio: aspectRatio, resolution, duration: totalDuration, refs: [] }, billingRates, ); - // 基础资产按图像挂牌预估;视频按挂牌秒价。最终按成功任务实扣。 + // 与实扣对齐:脚本(Seed 2.1 Pro 挂牌) + 默认出图挂牌×场次粗估 + 视频挂牌秒价×时长 + const applyTeam = (raw: number) => { + const multiplier = billingRates.multiplier || 1; + if (!(raw > 0)) return 0; + return multiplier === 1 ? raw : Math.max(1, Math.round(Number((raw * multiplier).toFixed(6)))); + }; + const scriptUnit = (() => { + const texts = modelConfigs.filter((m) => m.capability === "text" && m.status === "active"); + const script = texts.find((m) => m.name === QUICK_SCRIPT_MODEL) || texts.find((m) => m.is_default) || texts[0]; + const listed = pointsPerCallFromCatalog(script); + return listed != null && listed > 0 ? listed : QUICK_SCRIPT_POINTS_FALLBACK; + })(); const imageUnit = (() => { - const img = modelConfigs.find((m) => m.capability === "image" && m.status === "active") + const images = modelConfigs.filter((m) => m.capability === "image" && m.status === "active"); + // 与后端 get_default_model(IMAGE) 一致:优先 is_default,再回落影擎-Image2 名字 + const img = images.find((m) => m.is_default) + || images.find((m) => m.name === "gpt-image-2") + || images[0] || modelConfigs.find((m) => m.capability === "image"); const listed = pointsPerImageFromCatalog(img); - return listed != null && listed > 0 ? listed : 20; + return listed != null && listed > 0 ? listed : QUICK_IMAGE_POINTS_FALLBACK; })(); - const estimatedPoints = videoEstimate.listed ? videoEstimate.points + sceneCount * imageUnit : 0; + const estimatedPoints = videoEstimate.listed + ? videoEstimate.points + applyTeam(scriptUnit) + applyTeam(sceneCount * imageUnit) + : 0; const shellClass = [ "quick-create-shell", restoring ? "is-restoring" : "", diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 2e5e999..b7182d2 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -9,7 +9,8 @@ import { UserRound, Users, Wallet, - WandSparkles + WandSparkles, + type LucideIcon, } from "lucide-react"; export type Page = @@ -79,7 +80,7 @@ export type NavigateOptions = { }; export type NavigateFn = (page: Page, options?: NavigateOptions) => void; export type Notice = { type: "success" | "error" | "info"; text: string } | null; -export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: string }; +export type NavItem = { page: Page; label: string; icon: LucideIcon; badge?: string }; // 仅团队主账号可访问的团队级页面。入口与路由守卫共用此规则,避免权限逻辑分散。 const OWNER_ONLY_PAGES = new Set(["team", "account"]); diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 59575cc..cf7298a 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -588,6 +588,7 @@ export type ModelConfig = { display_name: string; capability: string; status: string; + is_default?: boolean; unit_price?: string; // 单位价(每张图/每次调用),前端据此算「预估扣费」与后端实扣一致(PMC#20) // 模型元数据:自由创作视频模型带 pricing(元/百万tokens 分档价表)/resolutions/durations,前端预估消耗读它 metadata?: Record;