feat(quotas): 媒体生成每账号每日上限 (默 20 图 / 5 视频, yaml 可配)

config/agent.yaml 加 quotas 段;core/storage/usage.py 加 check_daily_quota
(COUNT usage_events WHERE user_id+kind+created_at>=本地今日 00:00);
SeedreamTool / SeedanceTool ctor 收 daily_limit, execute() 起手 if 超额
返 [Error] 不调远端不烧钱。错误串只暴露已用/上限 + 重置时间,不写
yaml 路径 (避免 LLM 转述泄漏内部 schema 给外部用户)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-22 15:21:39 +08:00
parent 9cbe7311c1
commit 758486e2cd
6 changed files with 76 additions and 3 deletions

View File

@ -23,6 +23,7 @@
### 2026-05-22 ### 2026-05-22
- **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`config/agent.yaml` 加 `quotas` 段(`images_per_day: 20` / `videos_per_day: 5`),`core/storage/usage.py::check_daily_quota(user_id, kind, limit)` SELECT COUNT FROM usage_events WHERE user_id=? AND kind IN(image/video) AND created_at >= 本地今日 00:00,`limit<=0` 短路不查 DB。`SeedreamTool` / `SeedanceTool` ctor 新增 `daily_limit` 形参由 `agent_builder` 从 yaml 透传,`execute()` 起手 if 超额返 `[Error] 已达每日 X 生成上限(N/M),次日 00:00 重置` 不调远端不烧钱。tool 返串会进 LLM 上下文 → 模型据此对用户解释,所以**只暴露已用/上限 + 重置时间**,不写 `config/agent.yaml::quotas.X` 这种 yaml 路径(否则 LLM 倾向原文复述,SaaS 场景泄漏内部 schema 给外部用户;管理员要调改自己读代码/yaml 找,30 秒事)。**跨 task 跨 variant 全口径合计** —— 配额是账号级与具体 variant 无关(seedream + 未来的 seedream_pro 共享同一 20 张池)。失败任务不计 —— record_*_usage 只在成功+下载完才落库,失败 retry 不烧配额符合直觉。并发 race(同 user 跨 task 两次 check 同时过)可接受 —— 软上限非计费 hard cap,日级偶尔多 1 张不破坏保护意图,不加事务锁。否决:(a) env 变量(`ZCBOT_IMAGES_PER_DAY` 等)—— 配额是业务策略不是部署秘密 / 环境差异,跟现有 yaml 类参数(默认 size / 价格 / 超时)分工一致,且 yaml 带注释 + 多值组合扩展自然(未来加 audio_per_day);(b) AgentLoop 集中 pre-flight —— 给 loop 加配额映射反而散,tool 层自检每次只多一行 SQL 亚毫秒,符合"工具按原子操作切分";(c) 滑动 24h 窗口 —— 用户直觉是"今天用完了明天再来"的日历日,服务器本地 00:00 重置语义更顺;(d) tool 返串里贴 yaml 路径给管理员看 —— LLM 会向用户复述,泄漏内部 schema。
- **"+ 新建任务"按钮从 header 挪到任务面板 + 改通栏单独一行**:`web/static/dev.html` `#hd-new` 节点直接在 HTML 里挪到 `#pane-left`,放在第一行 `.pane-head`(任务标题 + 计数 + filter + 刷新 + 折叠)之下、搜索行之上的独立 `.pane-head` 行,`flex:1` 撑满整行(primary 红底通栏 CTA)。原本塞在第一行 spacer 之后,但 pane 320px 宽度下"+ 新建任务"中文五字会被挤断行,通栏解决根因。语义更贴(新建任务 = 任务面板的动作);顶栏减负只剩身份区(brand / 用户名 / 退出登录);两种模式 DOM 一致,顺手删了 `embedInit` 里动态 `insertBefore` 那段 + `@media phone``#hd-new` 紧凑覆盖(通栏环境不需要缩字号)。桌面 / 平板折叠态被 `#pane-left > * { display: none }` 自动藏掉,无需额外覆盖。 - **"+ 新建任务"按钮从 header 挪到任务面板 + 改通栏单独一行**:`web/static/dev.html` `#hd-new` 节点直接在 HTML 里挪到 `#pane-left`,放在第一行 `.pane-head`(任务标题 + 计数 + filter + 刷新 + 折叠)之下、搜索行之上的独立 `.pane-head` 行,`flex:1` 撑满整行(primary 红底通栏 CTA)。原本塞在第一行 spacer 之后,但 pane 320px 宽度下"+ 新建任务"中文五字会被挤断行,通栏解决根因。语义更贴(新建任务 = 任务面板的动作);顶栏减负只剩身份区(brand / 用户名 / 退出登录);两种模式 DOM 一致,顺手删了 `embedInit` 里动态 `insertBefore` 那段 + `@media phone``#hd-new` 紧凑覆盖(通栏环境不需要缩字号)。桌面 / 平板折叠态被 `#pane-left > * { display: none }` 自动藏掉,无需额外覆盖。
- **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=...`)**:`web/static/dev.html` 加 embed 模式 — 父页面 iframe 嵌入时藏左上 brand / 顶栏 `#hd-who` / 退出登录按钮(桌面段整层 header `display:none`,移动段保留 header 给 `.mobile-tabs` 切换用),JS `embedInit``#hd-new`("+ 新建任务")从 header 节点移到任务面板 pane-head(spacer 之后、`#filter-status` 之前,加 `small` class 跟周边按钮对齐高度)。postMessage 协议:iframe 启动发 `{type:"zcbot-ready"}` 给父端,父端调自家后端用 `PLATFORM_KEY` 走 zcbot 已有的 `POST /v1/auth/login` 拿 JWT,通过 `{type:"zcbot-token", token, user_id, user_name?}` 推回 iframe;iframe 写 localStorage + `enterApp()`。401 时改写 `logout()` 不再 `location.reload()`,而是发 `{type:"zcbot-401"}` 通知父端重换 token,期间显灰底等待层(`#embed-waiting` spinner + 文案);新加 css class `body.embed-mode` / `body.embed-mode.embed-waiting` 控制可见性。**安全要点**:`event.origin` 双向校验(白名单 = URL 参数 `parent_origin`),缺参数直接显错误占位拒收;`PLATFORM_KEY` 留在 platform 后端绝不下发浏览器。`web/EMBED.md` 写给 platform 工程的对接手册(URL / 协议 / Node/Python 后端示例 / 父端前端示例 / CORS / CSP frame-ancestors 收紧建议 / 调试 + 故障兜底表)。否决:(a) URL 参数直接传 token —— Referer / 浏览器历史泄漏面;(b) 同源 + 共享 localStorage —— 用户明确说不同源;(c) 拆 dev.html 进 platform SPA route —— 工作量爆炸。 - **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=...`)**:`web/static/dev.html` 加 embed 模式 — 父页面 iframe 嵌入时藏左上 brand / 顶栏 `#hd-who` / 退出登录按钮(桌面段整层 header `display:none`,移动段保留 header 给 `.mobile-tabs` 切换用),JS `embedInit``#hd-new`("+ 新建任务")从 header 节点移到任务面板 pane-head(spacer 之后、`#filter-status` 之前,加 `small` class 跟周边按钮对齐高度)。postMessage 协议:iframe 启动发 `{type:"zcbot-ready"}` 给父端,父端调自家后端用 `PLATFORM_KEY` 走 zcbot 已有的 `POST /v1/auth/login` 拿 JWT,通过 `{type:"zcbot-token", token, user_id, user_name?}` 推回 iframe;iframe 写 localStorage + `enterApp()`。401 时改写 `logout()` 不再 `location.reload()`,而是发 `{type:"zcbot-401"}` 通知父端重换 token,期间显灰底等待层(`#embed-waiting` spinner + 文案);新加 css class `body.embed-mode` / `body.embed-mode.embed-waiting` 控制可见性。**安全要点**:`event.origin` 双向校验(白名单 = URL 参数 `parent_origin`),缺参数直接显错误占位拒收;`PLATFORM_KEY` 留在 platform 后端绝不下发浏览器。`web/EMBED.md` 写给 platform 工程的对接手册(URL / 协议 / Node/Python 后端示例 / 父端前端示例 / CORS / CSP frame-ancestors 收紧建议 / 调试 + 故障兜底表)。否决:(a) URL 参数直接传 token —— Referer / 浏览器历史泄漏面;(b) 同源 + 共享 localStorage —— 用户明确说不同源;(c) 拆 dev.html 进 platform SPA route —— 工作量爆炸。
- **dev SPA chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈**:`web/static/dev.html` 给 `#chat-input``paste` 监听 —— `e.clipboardData.files` 非空时 `preventDefault` + 复用现有 `uploadFiles(files)``/v1/files/upload` 落到 `state.filesPath`(与拖拽到右 pane 同通路);纯文本粘贴走默认不拦。`uploadFiles` 改返回 bool(成功 true / 失败 false,原 alert 行为不变);粘贴 handler 通过 `chat-hint` 广播 "上传中:<name>…" → "已粘贴:<name>"(4s 后回前一个 hint,同 `optimizePrompt` 救回范式,不破坏 streaming/optimizing 期间的状态)。失败仍走 alert,hint 立即恢复。placeholder 提示加 `Ctrl+V 可粘贴文件`。常见场景:截图后直接 Ctrl+V 入对话区当作素材上传,免去切窗口走右 pane 拖拽。 - **dev SPA chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈**:`web/static/dev.html` 给 `#chat-input``paste` 监听 —— `e.clipboardData.files` 非空时 `preventDefault` + 复用现有 `uploadFiles(files)``/v1/files/upload` 落到 `state.filesPath`(与拖拽到右 pane 同通路);纯文本粘贴走默认不拦。`uploadFiles` 改返回 bool(成功 true / 失败 false,原 alert 行为不变);粘贴 handler 通过 `chat-hint` 广播 "上传中:<name>…" → "已粘贴:<name>"(4s 后回前一个 hint,同 `optimizePrompt` 救回范式,不破坏 streaming/optimizing 期间的状态)。失败仍走 alert,hint 立即恢复。placeholder 提示加 `Ctrl+V 可粘贴文件`。常见场景:截图后直接 Ctrl+V 入对话区当作素材上传,免去切窗口走右 pane 拖拽。

View File

@ -5,3 +5,10 @@ models_dir: config/models
skills_dir: skills skills_dir: skills
workspace_dir: workspace workspace_dir: workspace
system_prompt: prompts/system/general_v1.md system_prompt: prompts/system/general_v1.md
# 媒体生成每账号每日配额(usage_events.kind=image/video 计数,服务器本地 00:00 重置)。
# 失败任务不算(record_*_usage 只在成功 + 下载完才落库)。≤ 0 视为不限。
# 跨 task 跨 variant 全口径合计。改后 重启 web 生效。
quotas:
images_per_day: 20 # seedream 等图像 tool 调用上限
videos_per_day: 5 # seedance 等视频 tool 调用上限

View File

@ -355,6 +355,12 @@ def build_agent(
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp tools[rp.name] = rp
# 每账号每日配额(yaml `quotas` 段,跨 task 跨 variant 全口径合计;
# 0 / 缺失 = 不限)。tool 起手 check_daily_quota,超额返 [Error] 不调远端。
quotas = cfg.get("quotas") or {}
images_per_day = int(quotas.get("images_per_day", 0))
videos_per_day = int(quotas.get("videos_per_day", 0))
# 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 —— # 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 ——
# 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。 # 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。
# image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant # image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant
@ -384,6 +390,7 @@ def build_agent(
user_id=uid, user_id=uid,
base_dir=tool_base, base_dir=tool_base,
user_root=ur_path, user_root=ur_path,
daily_limit=images_per_day,
) )
tools[seedream_tool.name] = seedream_tool tools[seedream_tool.name] = seedream_tool
@ -413,6 +420,7 @@ def build_agent(
base_dir=tool_base, base_dir=tool_base,
user_root=ur_path, user_root=ur_path,
cancel_check=cancel_check, cancel_check=cancel_check,
daily_limit=videos_per_day,
) )
tools[seedance_tool.name] = seedance_tool tools[seedance_tool.name] = seedance_tool

View File

@ -9,11 +9,12 @@ chat 类型的入口由 loop.py 在 assistant message 入库后调用;媒体工
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any, Mapping, Optional from typing import Any, Mapping, Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import update from sqlalchemy import func, select, update
from .engine import session_scope from .engine import session_scope
from .models import Message, UsageEvent from .models import Message, UsageEvent
@ -190,3 +191,31 @@ def record_video_usage(
cost_cny=cost_cny, cost_cny=cost_cny,
)) ))
return cost_cny return cost_cny
def check_daily_quota(*, user_id: UUID, kind: str, limit: int) -> tuple[int, bool]:
"""每账号每日 kind=image/video 调用配额检查。返回 (今日已用次数, 是否超额)。
`limit <= 0` 视为不限,直接返回 (0, False) 不查 DB
"一天"=服务器本地 00:00 起算(用户直觉日历日;非滑动 24h 窗口)
失败任务不计 `record_*_usage` 只在成功+下载完才落库 失败 retry 不烧配额
task variant 全口径合计 配额是账号级,与具体调用哪个 variant 无关
并发 race: user 两次调用同时通过 check 会两个都跑成功(off-by-one),
可接受 task 单活 run gate 已经挡了同 task 并发; task 罕见,
且这是软上限(非计费 hard cap),日级偶尔多 1 张不影响保护意图
"""
if limit <= 0:
return 0, False
now_local = datetime.now().astimezone()
today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
with session_scope() as s:
used = s.execute(
select(func.count()).select_from(UsageEvent).where(
UsageEvent.user_id == user_id,
UsageEvent.kind == kind,
UsageEvent.created_at >= today_start,
)
).scalar_one()
used_int = int(used)
return used_int, used_int >= int(limit)

View File

@ -26,7 +26,7 @@ from typing import Any, Callable, Optional
from uuid import UUID from uuid import UUID
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_video_usage from core.storage.usage import check_daily_quota, record_video_usage
from .base import Tool from .base import Tool
@ -125,6 +125,7 @@ class SeedanceTool(Tool):
base_dir: Optional[Path] = None, base_dir: Optional[Path] = None,
user_root: Optional[Path] = None, user_root: Optional[Path] = None,
cancel_check: Optional[Callable[[], bool]] = None, cancel_check: Optional[Callable[[], bool]] = None,
daily_limit: int = 0,
) -> None: ) -> None:
super().__init__(base_dir, user_root=user_root) super().__init__(base_dir, user_root=user_root)
self.ark_cfg = ark_cfg self.ark_cfg = ark_cfg
@ -134,6 +135,7 @@ class SeedanceTool(Tool):
self.task_id = task_id self.task_id = task_id
self.user_id = user_id self.user_id = user_id
self.cancel_check = cancel_check # 轮询期间检查是否被 cancel self.cancel_check = cancel_check # 轮询期间检查是否被 cancel
self.daily_limit = int(daily_limit) # 0 / 负 = 不限;由 agent_builder 从 yaml quotas 透传
def execute( def execute(
self, self,
@ -147,6 +149,18 @@ class SeedanceTool(Tool):
if not (prompt or "").strip(): if not (prompt or "").strip():
return "[Error] prompt 不能为空" return "[Error] prompt 不能为空"
# 每账号每日配额(yaml quotas.videos_per_day)。失败 / cancel 不计,因为
# record_video_usage 只在 succeeded+下载完才落库。tool 返串会进 LLM 上下文
# → 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间),
# 内部 yaml 路径不进对话(管理员要改的地方读代码/yaml 自己找)。
if self.daily_limit > 0:
used, over = check_daily_quota(user_id=self.user_id, kind="video", limit=self.daily_limit)
if over:
return (
f"[Error] 已达每日视频生成上限({used}/{self.daily_limit} 个),"
f"次日 00:00 重置。"
)
cfg = self.cfg cfg = self.cfg
model_id = cfg["model_id"] model_id = cfg["model_id"]
chosen_resolution = resolution or cfg.get("default_resolution", "720p") chosen_resolution = resolution or cfg.get("default_resolution", "720p")

View File

@ -17,7 +17,7 @@ from typing import Any, Optional
from uuid import UUID from uuid import UUID
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage from core.storage.usage import check_daily_quota, record_image_usage
from .base import Tool from .base import Tool
@ -64,6 +64,7 @@ class SeedreamTool(Tool):
user_id: UUID, user_id: UUID,
base_dir: Optional[Path] = None, base_dir: Optional[Path] = None,
user_root: Optional[Path] = None, user_root: Optional[Path] = None,
daily_limit: int = 0,
) -> None: ) -> None:
super().__init__(base_dir, user_root=user_root) super().__init__(base_dir, user_root=user_root)
self.ark_cfg = ark_cfg self.ark_cfg = ark_cfg
@ -72,6 +73,7 @@ class SeedreamTool(Tool):
self.working_dir = Path(working_dir) self.working_dir = Path(working_dir)
self.task_id = task_id self.task_id = task_id
self.user_id = user_id self.user_id = user_id
self.daily_limit = int(daily_limit) # 0 / 负 = 不限;由 agent_builder 从 yaml quotas 透传
def execute( def execute(
self, self,
@ -83,6 +85,18 @@ class SeedreamTool(Tool):
if not (prompt or "").strip(): if not (prompt or "").strip():
return "[Error] prompt 不能为空" return "[Error] prompt 不能为空"
# 每账号每日配额(yaml quotas.images_per_day)。失败 retry 不计,因为
# record_image_usage 只在成功+下载完才落库。tool 返串会进 LLM 上下文,
# 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间),
# 内部 yaml 路径不进对话(管理员要改的地方读代码/yaml 自己找)。
if self.daily_limit > 0:
used, over = check_daily_quota(user_id=self.user_id, kind="image", limit=self.daily_limit)
if over:
return (
f"[Error] 已达每日图片生成上限({used}/{self.daily_limit} 张),"
f"次日 00:00 重置。"
)
cfg = self.cfg cfg = self.cfg
model_id = cfg["model_id"] model_id = cfg["model_id"]
chosen_size = size or cfg.get("default_size", "2048x2048") chosen_size = size or cfg.get("default_size", "2048x2048")