From 81f7955df05573376df7a84ed1433a05b846a06c Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 27 Aug 2026 16:24:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(render3d):=20=E6=AF=8F=E4=B8=AA=203D=20?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=9C=80=E5=A4=9A=203=20=E4=B8=AA=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本期试用限额,不是成本限制 —— 三渲二的动作由浏览器出帧,对我们几乎零成本。 作用域取"每个 3D 资产"而不是"每个用户":动作是从模型生成出来的,换个模型就是另一批 动作;按用户总数算的话,建了第二个模型却分不到名额。要改成按用户总数,换掉统计范围即可。 闸放在 HTTP 边界:任务还没建、积分还没冻,拒起来干净;走到执行阶段才失败的话,用户看到 的是通用的"生成失败",不知道是撞了限额。 只管三渲二:没有 3D 资产的造型走 i2v,那条路线有自己的成本与限额,在这里被拒的话用户会 被一个跟他无关的限额挡住。character_data 解析失败时放行 —— 与 _outfit_model_3d_url 同一个取舍:放行只是少拦一次,拦错是把用户卡死且看不出原因。 Closes #829 --- .../app/src/windup_app/web/api/generation.py | 36 +++++++++ backend/tests/test_render3d_action_cap.py | 81 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 backend/tests/test_render3d_action_cap.py diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index e1a44017..9b49a6b5 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -498,6 +498,41 @@ def _outfit_model_3d_url(character: Character, outfit_id: str | None) -> str | N return (outfit.model_3d_url or "").strip() or None +#: 一个 3D 资产下最多能有几个动作。 +#: +#: 这条是**产品限额**不是技术限制:三渲二的动作由浏览器出帧,对我们几乎零成本, +#: 限的是本期试用范围。作用域取"每个 3D 资产"而不是"每个用户":动作是从模型生成 +#: 出来的,换个模型就是另一批动作;按用户总数算的话,建了第二个模型却分不到名额。 +#: 要改成按用户总数,把 ``_owned_3d_action_count`` 的统计范围换掉即可。 +MAX_ACTIONS_PER_3D_ASSET = 3 + + +def _require_3d_action_quota(character: Character, outfit_id: str | None) -> None: + """三渲二动作的条数上限。**只管三渲二** —— i2v 那条路线不受此限。 + + 在 HTTP 边界就拒:任务还没建、积分还没冻,拒起来干净;而超限这件事与用户输入无关, + 让它走到执行阶段才失败的话,用户看到的是通用的"生成失败",不知道是撞了限额。 + """ + if not outfit_id: + return + try: + data = CharacterData.model_validate(character.character_data or {}) + except ValidationError: + # 与 ``_outfit_model_3d_url`` 同一个取舍:结构脏了不该让生成起不来。 + # 这里放行的后果只是少拦一次,而拦错的后果是用户被卡住且看不出原因。 + return + outfit = next((o for o in data.outfits if o.id == outfit_id), None) + if outfit is None or not (outfit.model_3d_url or "").strip(): + return # 没有 3D 资产 = 走 i2v,不归本闸管 + if len(outfit.actions) >= MAX_ACTIONS_PER_3D_ASSET: + raise BizException( + f"这个 3D 角色已经有 {len(outfit.actions)} 个动作了," + f"本期每个 3D 角色最多 {MAX_ACTIONS_PER_3D_ASSET} 个。" + "删掉一个再来,或改走视频路线。", + code=BizCode.BAD_REQUEST, + ) + + def _require_master(model_3d_url: str | None, reference_image_urls: list[str]) -> None: """动作生成拿不到母版就当场拒收,不收下一个注定在执行阶段失败的任务。""" # 判据照抄执行器的取母版逻辑(``ActionTaskExecutor._produce_action``):有 3D 资产走 @@ -722,6 +757,7 @@ def submit_action_generation( _validate_project_direction(project, body.direction) character = _get_character_or_raise(session, body.character_id, body.project_id) model_3d_url = _outfit_model_3d_url(character, body.outfit_id) + _require_3d_action_quota(character, body.outfit_id) _require_master(model_3d_url, body.reference_image_urls) input_data = CharacterActionInput( character_id=body.character_id, diff --git a/backend/tests/test_render3d_action_cap.py b/backend/tests/test_render3d_action_cap.py new file mode 100644 index 00000000..8fe7c431 --- /dev/null +++ b/backend/tests/test_render3d_action_cap.py @@ -0,0 +1,81 @@ +"""三渲二动作条数上限。 + +限的是本期试用范围,不是成本 —— 三渲二的动作由浏览器出帧,对我们几乎零成本。 +但闸口本身要拦对:拦错的方向是把走 i2v 的用户一起卡住,而 i2v 那条路线不归它管。 +""" +from __future__ import annotations + +import pytest + +from windup_app.web.api.generation import ( + MAX_ACTIONS_PER_3D_ASSET, + _require_3d_action_quota, +) +from windup_common.exceptions import BizException + + +class _Char: + def __init__(self, data): + self.id = 1 + self.character_data = data + + +def _outfit(*, model_3d_url, n_actions): + return { + "version": 1, + "templates": [], + "outfits": [{ + "id": "o1", "name": "默认", "model_3d_url": model_3d_url, + # 字段名必须与 ``CharacterAction`` 一致(``type`` / ``frame_count``)。 + # 写错的话 model_validate 会失败,请求被"脏数据放行"那条兜住, + # 于是用例明明在测上限却什么都没测到(本用例第一版就是这么绿的)。 + "actions": [ + {"id": f"a{i}", "name": f"动作{i}", "type": "walk", "frame_count": 8} + for i in range(n_actions) + ], + }], + } + + +def test_a_3d_asset_at_the_cap_is_refused(): + """到上限就拒 —— 这是这个闸唯一要做的事。""" + c = _Char(_outfit(model_3d_url="https://media/x.glb", n_actions=MAX_ACTIONS_PER_3D_ASSET)) + with pytest.raises(BizException) as e: + _require_3d_action_quota(c, "o1") + assert str(MAX_ACTIONS_PER_3D_ASSET) in str(e.value) + + +def test_below_the_cap_passes(): + c = _Char(_outfit(model_3d_url="https://media/x.glb", n_actions=MAX_ACTIONS_PER_3D_ASSET - 1)) + _require_3d_action_quota(c, "o1") + + +def test_an_outfit_without_a_3d_model_is_not_capped(): + """拦的坏例:把走 i2v 的用户一起卡住。 + + 这个闸只管三渲二。没有 3D 资产的造型走的是视频路线,它有自己的成本与限额, + 在这里被拒的话用户会被一个跟他无关的限额挡住,而且看不出原因。 + """ + c = _Char(_outfit(model_3d_url=None, n_actions=99)) + _require_3d_action_quota(c, "o1") + + +def test_no_outfit_id_means_i2v_and_is_not_capped(): + """不带 outfit_id 就是 i2v 路线(判据与 ``_outfit_model_3d_url`` 一致),不归本闸管。""" + c = _Char(_outfit(model_3d_url="https://media/x.glb", n_actions=99)) + _require_3d_action_quota(c, None) + + +def test_unparseable_character_data_lets_the_request_through(): + """拦的坏例:``character_data`` 里某个无关字段脏了就把用户卡死。 + + 与 ``_outfit_model_3d_url`` 同一个取舍:放行的后果只是少拦一次, + 拦错的后果是用户被卡住且看不出原因。 + """ + _require_3d_action_quota(_Char({"outfits": "不是列表"}), "o1") + + +def test_an_unknown_outfit_is_left_to_the_route_resolver(): + """未知造型不在这里报错 —— ``_outfit_model_3d_url`` 已经会报 NOT_FOUND, + 两处各报一次的话,先跑的那个决定了用户看到哪句话,而这里这句是错的。""" + _require_3d_action_quota(_Char(_outfit(model_3d_url="u", n_actions=99)), "不存在")