Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions backend/packages/app/src/windup_app/web/api/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reserve pending 3D action submissions before counting

len(outfit.actions) 只统计已经写回 character_data 的动作,但 /generation/action 这里只创建 PENDING 任务;按当前契约,CharacterActionOutput 是任务完成后由前端再写入 outfits[].actions[]。因此当资产有 0、1 或 2 个已保存动作时,可以连续/并发提交任意多个请求,每个请求都会在这个检查看到同一个旧计数并成功创建任务,最终超过“每个资产最多 3 个动作”。请在 HTTP 提交事务中原子预留名额(或把该资产的未完成 3D 动作任务纳入带锁的计数),再创建任务。

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 资产走
Expand Down Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_render3d_action_cap.py
Original file line number Diff line number Diff line change
@@ -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)), "不存在")
Loading