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:
parent
9cbe7311c1
commit
758486e2cd
|
|
@ -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 拖拽。
|
||||||
|
|
|
||||||
|
|
@ -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 调用上限
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue