mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:01:59 +00:00
合入 W3-d 邮件、待办汇总、分享链接:邮件渠道(SMTP 可选、口令加密、退信不追踪);每天的待办汇总与访问密钥到期提醒;分享链接(只存哈希、必设到期、只显示定时出数的视图、/s 页不进外壳、下载报告);「最近一次封存」按来源白名单认;使用者拿不到邮件渠道的收件地址;同站门户嵌整站的配置说明
This commit is contained in:
+120
-39
@@ -7,6 +7,11 @@
|
||||
个人渠道谁都能建(权限最低的使用者也能),地址另过一遍 SSRF 检查(tools/net.py,不开内网),保存和发送时各一次:
|
||||
不然服务端替它往内网地址发 POST,测试接口交回的状态码和报错就成了探测内网的信号(A6.2:只有登记的端点能访问内网)。
|
||||
全局、应用的渠道只有搭建者以上能建,和定时任务的 Webhook 同一个信任级别,地址照旧只查格式(内网的接收端很常见)。
|
||||
|
||||
邮件(第五种格式,notify/mail.py,04 细案 B2):个人邮件渠道不收地址,只发到本人在名录里的账号邮箱(能填任意地址的话,个人
|
||||
渠道就成了谁都能用的邮件中继);全局、应用的邮件渠道填固定的收件地址,最多 20 个。邮件服务(设置的 mail 一组)没启用时
|
||||
不收新的邮件渠道:建了也发不出去,就是「看着生效、其实没生效」。渠道列表另给 mail:邮件服务启没启用、本人的账号邮箱,
|
||||
设置页据此决定列不列「邮件」这一格式、个人邮件渠道写「发到账号邮箱 …」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,7 +25,7 @@ from app.api.deps import current_principal
|
||||
from app.core.crypto import encrypt
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import NotifyChannel, NotifyDelivery
|
||||
from app.notify import core
|
||||
from app.notify import core, mail
|
||||
from app.notify.service import EVENTS, channel_out, subscribe_problem
|
||||
|
||||
router = APIRouter(prefix="/api/notify", tags=["notify"])
|
||||
@@ -64,25 +69,76 @@ async def _safe_personal(scope: str, url: str) -> None:
|
||||
def _validated(payload: ChannelIn, *, url: str | None, saved: list[str] | None = None) -> None:
|
||||
"""saved 是渠道已经订阅着的事件:只核对这次新勾的(notify/service.subscribe_problem)。以前存下的照旧——当时界面对
|
||||
哪种渠道都列全部事件,现在才按事件推给哪几条路核对,不能让这些渠道连开关、改名都 422。"""
|
||||
if payload.format not in core.FORMATS:
|
||||
raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL.values())}之一")
|
||||
if payload.format not in core.CHANNEL_FORMATS:
|
||||
raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL[f] for f in core.CHANNEL_FORMATS)}之一")
|
||||
for event in dict.fromkeys(payload.events):
|
||||
if event not in (saved or ()) and (problem := subscribe_problem(event, payload.scope)):
|
||||
raise HTTPException(422, problem)
|
||||
if url is not None and (problem := core.format_problem(payload.format, url)):
|
||||
if url is not None and payload.format != "email" and (problem := core.format_problem(payload.format, url)):
|
||||
raise HTTPException(422, problem)
|
||||
|
||||
|
||||
#: 个人邮件渠道带了地址:不收(见模块说明)
|
||||
PERSONAL_MAIL_NO_URL = "个人邮件渠道只发到你的账号邮箱,不用填地址"
|
||||
|
||||
|
||||
async def _mail_target(scope: str, url: str | None, *, saved: str | None = None) -> str:
|
||||
"""邮件渠道 url 列存什么(明文,调用方加密):个人渠道存占位,全局、应用的是校验过的收件地址(逗号分隔)。
|
||||
url 没给、原来就是邮件渠道的(saved)沿用原来的地址。邮件服务没启用时不收(见模块说明)。"""
|
||||
from app.db.base import SessionLocal
|
||||
|
||||
async with SessionLocal() as session:
|
||||
cfg = await mail.load_config(session)
|
||||
if not cfg.enabled:
|
||||
raise HTTPException(422, mail.NOT_ENABLED)
|
||||
if scope == "user":
|
||||
if url and url.strip():
|
||||
raise HTTPException(422, PERSONAL_MAIL_NO_URL)
|
||||
return mail.PERSONAL_PLACEHOLDER
|
||||
if not (url and url.strip()):
|
||||
if saved:
|
||||
return saved
|
||||
raise HTTPException(422, "请填写收件地址,多个地址用逗号或换行分隔")
|
||||
try:
|
||||
return ", ".join(mail.parse_recipients(url))
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
|
||||
|
||||
async def _owner_emails(rows: list[NotifyChannel]) -> dict[str, str | None]:
|
||||
"""个人邮件渠道的拥有者 → 账号邮箱(channel_out 写「发到账号邮箱 …」用)。"""
|
||||
from app.db.models import User
|
||||
|
||||
owners = {r.owner_id for r in rows if r.scope == "user" and r.format == "email" and r.owner_id}
|
||||
if not owners:
|
||||
return {}
|
||||
async with SessionLocal() as session:
|
||||
found = (await session.execute(select(User.id, User.email).where(User.id.in_(owners)))).all()
|
||||
return {uid: email for uid, email in found}
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
async def list_channels() -> dict[str, Any]:
|
||||
"""{items}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。"""
|
||||
"""{items, mail}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。mail 是邮件服务启没启用、本人的账号邮箱
|
||||
(关登录时没有账号,为 null):设置页据此列不列「邮件」格式、个人邮件渠道发到哪。
|
||||
|
||||
全局、应用渠道对谁都列(应用的通知里要挑渠道),但邮件渠道的收件地址只给改得了它的人(同 _can_edit:关登录时谁都能改,
|
||||
开了登录要搭建者以上),别人只拿到条数(channel_out 的 recipients)。"""
|
||||
from app.db.models import User
|
||||
|
||||
principal = current_principal()
|
||||
stmt = select(NotifyChannel).order_by(NotifyChannel.created_at)
|
||||
if principal.mode != "off":
|
||||
# 个人渠道只给本人看
|
||||
stmt = stmt.where(or_(NotifyChannel.scope != "user", NotifyChannel.owner_id == principal.user_id))
|
||||
async with SessionLocal() as session:
|
||||
return {"items": [channel_out(r) for r in (await session.execute(stmt)).scalars()]}
|
||||
rows = list((await session.execute(stmt)).scalars())
|
||||
cfg = await mail.load_config(session)
|
||||
mine = await session.get(User, principal.user_id) if principal.mode != "off" and principal.user_id else None
|
||||
emails = await _owner_emails(rows)
|
||||
editor = principal.mode == "off" or principal.at_least("builder")
|
||||
return {"items": [channel_out(r, emails.get(r.owner_id or ""), recipients=editor) for r in rows],
|
||||
"mail": {"enabled": cfg.enabled, "account_email": (mine.email or None) if mine is not None else None}}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
@@ -96,19 +152,25 @@ async def list_events() -> list[dict[str, Any]]:
|
||||
async def create_channel(payload: ChannelIn) -> dict[str, Any]:
|
||||
owner = current_principal().user_id if payload.scope == "user" else payload.owner_id
|
||||
_can_edit(payload.scope, owner)
|
||||
try:
|
||||
url = core.check_url(payload.url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
_validated(payload, url=url)
|
||||
await _safe_personal(payload.scope, url)
|
||||
if payload.format == "email":
|
||||
_validated(payload, url=None)
|
||||
url = await _mail_target(payload.scope, payload.url)
|
||||
secret = None # 邮件没有签名密钥
|
||||
else:
|
||||
try:
|
||||
url = core.check_url(payload.url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
_validated(payload, url=url)
|
||||
await _safe_personal(payload.scope, url)
|
||||
secret = encrypt(payload.secret) if payload.secret else None
|
||||
row = NotifyChannel(name=payload.name.strip(), scope=payload.scope, owner_id=owner, format=payload.format,
|
||||
url=encrypt(url), secret=encrypt(payload.secret) if payload.secret else None,
|
||||
events=list(dict.fromkeys(payload.events)), enabled=payload.enabled)
|
||||
url=encrypt(url), secret=secret, events=list(dict.fromkeys(payload.events)),
|
||||
enabled=payload.enabled)
|
||||
async with SessionLocal() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return channel_out(row)
|
||||
return channel_out(row, (await _owner_emails([row])).get(row.owner_id or ""))
|
||||
|
||||
|
||||
class ChannelPatch(BaseModel):
|
||||
@@ -144,29 +206,40 @@ async def update_channel(channel_id: str, payload: ChannelIn) -> dict[str, Any]:
|
||||
_can_edit(row.scope, row.owner_id)
|
||||
if payload.scope != row.scope:
|
||||
raise HTTPException(422, "通知渠道的范围建好之后不能修改,请新建一个")
|
||||
url = None
|
||||
if payload.url:
|
||||
try:
|
||||
url = core.check_url(payload.url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
if url is None:
|
||||
from app.core.crypto import decrypt
|
||||
from app.core.crypto import decrypt
|
||||
|
||||
current = decrypt(row.url) or ""
|
||||
_validated(payload, url=current or None, saved=list(row.events or []))
|
||||
url = None
|
||||
if payload.format == "email":
|
||||
_validated(payload, url=None, saved=list(row.events or []))
|
||||
# 只开关、改名、改订阅(地址不传)的不重新核对邮件服务:停用了邮件服务,已有的渠道照样能关掉、改名
|
||||
if payload.url or row.format != "email":
|
||||
saved = decrypt(row.url) if row.format == "email" and row.scope != "user" else None
|
||||
row.url = encrypt(await _mail_target(row.scope, payload.url, saved=saved))
|
||||
row.secret = None
|
||||
else:
|
||||
_validated(payload, url=url, saved=list(row.events or []))
|
||||
await _safe_personal(row.scope, url)
|
||||
row.url = encrypt(url)
|
||||
if payload.url:
|
||||
try:
|
||||
url = core.check_url(payload.url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
elif row.format == "email":
|
||||
# 邮件渠道换成 Webhook:原来存的是收件地址,不是能发的 Webhook 地址
|
||||
raise HTTPException(422, "换成 Webhook 格式要填写 Webhook 地址")
|
||||
if url is None:
|
||||
current = decrypt(row.url) or ""
|
||||
_validated(payload, url=current or None, saved=list(row.events or []))
|
||||
else:
|
||||
_validated(payload, url=url, saved=list(row.events or []))
|
||||
await _safe_personal(row.scope, url)
|
||||
row.url = encrypt(url)
|
||||
if payload.secret is not None:
|
||||
row.secret = encrypt(payload.secret) if payload.secret else None
|
||||
row.name, row.format = payload.name.strip(), payload.format
|
||||
row.events, row.enabled = list(dict.fromkeys(payload.events)), payload.enabled
|
||||
if payload.secret is not None:
|
||||
row.secret = encrypt(payload.secret) if payload.secret else None
|
||||
if row.scope == "app":
|
||||
row.owner_id = payload.owner_id
|
||||
await session.commit()
|
||||
return channel_out(row)
|
||||
return channel_out(row, (await _owner_emails([row])).get(row.owner_id or ""))
|
||||
|
||||
|
||||
@router.delete("/channels/{channel_id}", status_code=204)
|
||||
@@ -191,7 +264,8 @@ async def test_channel(channel_id: str) -> dict[str, Any]:
|
||||
raise HTTPException(404, "通知渠道不存在,可能已被删除")
|
||||
_can_edit(row.scope, row.owner_id)
|
||||
message = {"event": "test", "title": f"通知渠道「{row.name}」测试", "ok": True, "retry": False,
|
||||
"lines": [("说明", "这是一条测试消息。收到即表示渠道地址配置正确"
|
||||
"lines": [("说明", ("这是一封测试邮件。收到即表示邮件服务和收件地址配置正确" if row.format == "email" else
|
||||
"这是一条测试消息。收到即表示渠道地址配置正确")
|
||||
+ (";请求头带有签名,接收端可据此核对签名密钥" if row.secret and row.format == "generic"
|
||||
else ""))]}
|
||||
outcome = await send(row, message)
|
||||
@@ -201,6 +275,10 @@ async def test_channel(channel_id: str) -> dict[str, Any]:
|
||||
attempts=outcome.attempts, error=outcome.error))
|
||||
await session.commit()
|
||||
if outcome.ok:
|
||||
if row.format == "email":
|
||||
# 交给了邮件服务器不等于进了收件箱:退信不追踪,照实说
|
||||
return {"ok": True, "http_status": None,
|
||||
"message": f"{outcome.error or mail.HANDED_OFF},是否送达以邮件服务器为准,请到收件箱确认"}
|
||||
return {"ok": True, "http_status": outcome.http_status, "message": "请到接收端确认已收到"}
|
||||
return {"ok": False, "http_status": outcome.http_status, "message": outcome.error or "发送失败"}
|
||||
|
||||
@@ -212,12 +290,15 @@ async def list_deliveries(
|
||||
task_id: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> dict[str, Any]:
|
||||
"""投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。"""
|
||||
stmt = select(NotifyDelivery).order_by(NotifyDelivery.created_at.desc()).limit(limit)
|
||||
"""投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。
|
||||
|
||||
format 是渠道的格式:邮件渠道成功的那条界面上写「已交给邮件服务器」,不写「已送达」(退信不追踪)。"""
|
||||
stmt = (select(NotifyDelivery, NotifyChannel.format)
|
||||
.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id)
|
||||
.order_by(NotifyDelivery.created_at.desc()).limit(limit))
|
||||
principal = current_principal()
|
||||
if principal.mode != "off" and not principal.at_least("builder"):
|
||||
stmt = stmt.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id).where(
|
||||
NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id)
|
||||
stmt = stmt.where(NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id)
|
||||
if channel_id:
|
||||
stmt = stmt.where(NotifyDelivery.channel_id == channel_id)
|
||||
if run_id:
|
||||
@@ -225,9 +306,9 @@ async def list_deliveries(
|
||||
if task_id:
|
||||
stmt = stmt.where(NotifyDelivery.task_id == task_id)
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(stmt)).scalars()
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {"items": [{
|
||||
"id": r.id, "channel_id": r.channel_id, "subject_kind": r.subject_kind, "subject_id": r.subject_id,
|
||||
"event": r.event, "run_id": r.run_id, "task_id": r.task_id, "status": r.status,
|
||||
"http_status": r.http_status, "attempts": r.attempts, "error": r.error,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]}
|
||||
"http_status": r.http_status, "attempts": r.attempts, "error": r.error, "format": fmt,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None} for r, fmt in rows]}
|
||||
|
||||
+95
-13
@@ -613,8 +613,9 @@ def catalog_draft_values(stored: Any) -> dict[str, Any]:
|
||||
#: 全站只认一个对外地址(core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL 设了的话这一项不生效、不能改(PUT 回 409)。
|
||||
#: 键名沿用 notify 一组,界面上在「设置 → 通用」
|
||||
NOTIFY_GROUP = public_url.SETTING_GROUP
|
||||
NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None}
|
||||
NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址"}
|
||||
#: digest_at:每天几点给订阅了「每天的待办汇总」的个人渠道发汇总(HH:MM,按服务端所在时区;notify/digest.py,04 细案 B3)
|
||||
NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None, "digest_at": "09:00"}
|
||||
NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址", "digest_at": "待办汇总的发送时间"}
|
||||
#: GET /api/settings 另给的只读一组:{value: 生效的对外访问地址, env_locked: 是不是环境变量指定的}。设置页据此显示
|
||||
#: 「由环境变量指定」;PUT 带上它也不落库(同 limits)
|
||||
PUBLIC_URL_GROUP = "public_url"
|
||||
@@ -641,15 +642,25 @@ def notify_problem(value: Any) -> str | None:
|
||||
if key not in NOTIFY_DEFAULTS:
|
||||
return (f"「通知」设置中不支持「{key}」,可设置的项:"
|
||||
+ "、".join(f"「{NOTIFY_SETTING_LABEL[k]}」" for k in NOTIFY_DEFAULTS))
|
||||
if problem := _base_url_problem(item):
|
||||
if key == "digest_at":
|
||||
from app.notify.digest import time_ok
|
||||
|
||||
if not time_ok(item):
|
||||
return f"「{NOTIFY_SETTING_LABEL[key]}」需要是 24 小时制的时间,例如 09:00(从 00:00 到 23:59),当前为「{item}」"
|
||||
elif problem := _base_url_problem(item):
|
||||
return problem
|
||||
return None
|
||||
|
||||
|
||||
def notify_values(stored: Any) -> dict[str, Any]:
|
||||
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。"""
|
||||
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。
|
||||
汇总时间存坏了按缺省。"""
|
||||
from app.notify.digest import time_ok
|
||||
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY))}
|
||||
at = stored.get("digest_at")
|
||||
return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY)),
|
||||
"digest_at": at.strip() if time_ok(at) else NOTIFY_DEFAULTS["digest_at"]}
|
||||
|
||||
|
||||
async def notify_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
@@ -676,8 +687,11 @@ def public_url_values(stored_notify: Any) -> dict[str, Any]:
|
||||
#: user_personal_apps:开了登录时使用者能不能建个人应用、刷新、提交审核(默认关,同问数据的开关;搭建者及以上不受它管)。
|
||||
#: 关登录时它照样生效:关着就没有个人应用的入口。只有管理员能改(整个设置接口的改动都要管理员)
|
||||
APPS_GROUP = "apps"
|
||||
APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False}
|
||||
APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用"}
|
||||
#: share_links:能不能建分享链接(只读、不用登录就能看的视图页,api/shares.py,04 细案 C2、集成-I6)。缺省关;关掉时已有的
|
||||
#: 分享链接一并打不开(管理员随时收得回来),撤销照常能做
|
||||
APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}
|
||||
APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用",
|
||||
"share_links": "允许建分享链接"}
|
||||
#: 上限的上限:再大就等于不限,要不限的不该靠这一项
|
||||
APPS_DAILY_LIMIT_MAX = 100_000
|
||||
|
||||
@@ -694,7 +708,7 @@ def apps_problem(value: Any) -> str | None:
|
||||
if key not in APPS_DEFAULTS:
|
||||
return (f"「应用」设置中不支持「{key}」,可设置的项:"
|
||||
+ "、".join(f"「{APPS_SETTING_LABEL[k]}」" for k in APPS_DEFAULTS))
|
||||
if key == "user_personal_apps":
|
||||
if key in ("user_personal_apps", "share_links"):
|
||||
if not isinstance(item, bool):
|
||||
return f"「{APPS_SETTING_LABEL[key]}」只能是开或关,当前为「{item}」"
|
||||
elif not _daily_limit_ok(item):
|
||||
@@ -708,8 +722,10 @@ def apps_values(stored: Any) -> dict[str, Any]:
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
limit = stored.get("on_demand_daily_limit")
|
||||
personal = stored.get("user_personal_apps")
|
||||
shares = stored.get("share_links")
|
||||
return {"on_demand_daily_limit": limit if _daily_limit_ok(limit) else APPS_DEFAULTS["on_demand_daily_limit"],
|
||||
"user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"]}
|
||||
"user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"],
|
||||
"share_links": shares if isinstance(shares, bool) else APPS_DEFAULTS["share_links"]}
|
||||
|
||||
|
||||
async def apps_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
@@ -843,6 +859,15 @@ async def eval_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
return eval_values(row.value if row else None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
MAIL_GROUP = "mail"
|
||||
#: 发测试邮件没有可发的账号(关登录)、账号没有邮箱时 POST /api/settings/mail/test 回 422 的机读码
|
||||
MAIL_TEST_NO_ADDRESS = "mail_test_no_address"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 用户设置
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -856,6 +881,19 @@ def _hidden_groups() -> tuple[str, ...]:
|
||||
return (AUTH_KEY,)
|
||||
|
||||
|
||||
def _mail_public(stored: Any) -> dict[str, Any]:
|
||||
from app.notify import mail
|
||||
|
||||
return mail.public_value(stored)
|
||||
|
||||
|
||||
def _internal(key: str) -> bool:
|
||||
"""通知自己记的账(每天汇总的认领行、自动建过邮件渠道的人,notify/digest.STATE_PREFIX):不是用户设置,不列、不收。"""
|
||||
from app.notify.digest import STATE_PREFIX
|
||||
|
||||
return key.startswith(STATE_PREFIX)
|
||||
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
"ui": {"theme": "system", "language": "zh", "canvas_snap": True, "autosave": True},
|
||||
"run": {
|
||||
@@ -886,12 +924,14 @@ DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
NOTIFY_GROUP: notify_values(None),
|
||||
# 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型
|
||||
CATALOG_DRAFT_GROUP: catalog_draft_values(None),
|
||||
# 应用:按需视图的每日上限、使用者能不能建个人应用(管理员在设置页改)
|
||||
# 应用:按需视图的每日上限、使用者能不能建个人应用、能不能建分享链接(管理员在设置页改)
|
||||
APPS_GROUP: apps_values(None),
|
||||
# 口径账本:自动按口径正式出具的开关、每日上限,口径比对的时限
|
||||
LEDGER_GROUP: ledger_values(None),
|
||||
# 评测:预算的全站缺省(题集上可以改)、保留几次明细、结论裁判的缺省开关
|
||||
EVAL_GROUP: eval_values(None),
|
||||
# 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关
|
||||
MAIL_GROUP: _mail_public(None),
|
||||
}
|
||||
|
||||
|
||||
@@ -947,6 +987,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
|
||||
stored.pop(EVAL_SPEND, None)
|
||||
for key in _hidden_groups():
|
||||
stored.pop(key, None)
|
||||
stored = {k: v for k, v in stored.items() if not _internal(k)}
|
||||
merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()}
|
||||
# 裁判、核查设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
|
||||
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
|
||||
@@ -957,6 +998,8 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
|
||||
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
|
||||
merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP))
|
||||
merged[EVAL_GROUP] = eval_values(stored.get(EVAL_GROUP))
|
||||
# 口令不回(password 恒为 null),只说配没配(password_set)
|
||||
merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP))
|
||||
for key, value in stored.items():
|
||||
merged.setdefault(key, value)
|
||||
return merged
|
||||
@@ -1046,8 +1089,10 @@ async def put_settings(
|
||||
if problem := notify_problem(payload.values[NOTIFY_GROUP]):
|
||||
raise HTTPException(422, problem)
|
||||
# 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠
|
||||
payload.values[NOTIFY_GROUP] = notify_values(payload.values[NOTIFY_GROUP])
|
||||
if public_url.env_locked() and payload.values[NOTIFY_GROUP] != await notify_settings(session):
|
||||
# 只给了一部分的(只改汇总时间)补上存着的另几项,不把对外访问地址冲成没填
|
||||
payload.values[NOTIFY_GROUP] = notify_values({**await notify_settings(session), **payload.values[NOTIFY_GROUP]})
|
||||
current_url = (await notify_settings(session))[public_url.SETTING_KEY]
|
||||
if public_url.env_locked() and payload.values[NOTIFY_GROUP][public_url.SETTING_KEY] != current_url:
|
||||
# 环境变量指定了对外访问地址:改这一项不会生效,照实拒绝,不悄悄存一个用不上的值。原样写回(整组回写)不算改
|
||||
raise CodedHTTPException(
|
||||
409, f"对外访问地址由环境变量 {public_url.ENV_NAME} 指定({public_url.env_url()}),不能在设置里修改。"
|
||||
@@ -1064,10 +1109,18 @@ async def put_settings(
|
||||
if problem := eval_problem(payload.values[EVAL_GROUP]):
|
||||
raise HTTPException(422, problem)
|
||||
payload.values[EVAL_GROUP] = eval_values(payload.values[EVAL_GROUP])
|
||||
if MAIL_GROUP in payload.values:
|
||||
from app.notify import mail
|
||||
|
||||
saved_mail = await session.get(Setting, MAIL_GROUP)
|
||||
saved_value = saved_mail.value if saved_mail is not None else None
|
||||
if problem := mail.settings_problem(payload.values[MAIL_GROUP], saved_value):
|
||||
raise HTTPException(422, problem)
|
||||
payload.values[MAIL_GROUP] = mail.stored_value(payload.values[MAIL_GROUP], saved_value)
|
||||
if found := await _provider_problem(session, payload.values):
|
||||
raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0])
|
||||
for key, value in payload.values.items():
|
||||
if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, EVAL_SPEND, *_hidden_groups()):
|
||||
if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, EVAL_SPEND, *_hidden_groups()) or _internal(key):
|
||||
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
|
||||
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的、public_url 是算出来的,写进库也不生效。
|
||||
# 裁判、核查的每日计数是引擎记的账,只有引擎自己写。登录设置只经 /api/auth/settings 改(_hidden_groups)
|
||||
@@ -1084,6 +1137,35 @@ async def put_settings(
|
||||
return await get_settings(session)
|
||||
|
||||
|
||||
@router.post("/settings/mail/test")
|
||||
async def test_mail(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(只发到认证过的账号,不收任意地址:不让这个接口成了
|
||||
邮件中继)。没启用也能测(配好了先试一封再启用);不重试。{ok, message}:成功说「已交给邮件服务器」,是否送达以邮件服务器
|
||||
为准(退信不追踪)。关登录时没有账号、账号没有邮箱:422(mail_test_no_address),说清怎么办。"""
|
||||
from app.api.deps import current_principal
|
||||
from app.db.models import User
|
||||
from app.notify import mail
|
||||
from app.notify.core import Message
|
||||
|
||||
principal = current_principal()
|
||||
if principal.mode == "off" or not principal.user_id:
|
||||
raise CodedHTTPException(422, "关闭登录时没有账号邮箱,测试邮件发不到人:请建一个全局的邮件渠道、填上收件地址,"
|
||||
"用渠道的「发送测试消息」试", MAIL_TEST_NO_ADDRESS)
|
||||
user = await session.get(User, principal.user_id)
|
||||
to = (user.email or "").strip() if user is not None else ""
|
||||
if not mail.address_ok(to):
|
||||
raise CodedHTTPException(422, f"{mail.NO_EMAIL},再发测试邮件", MAIL_TEST_NO_ADDRESS)
|
||||
row = await session.get(Setting, MAIL_GROUP)
|
||||
cfg = mail.config_of(row.value if row is not None else None)
|
||||
if not cfg.ready:
|
||||
return {"ok": False, "message": f"请先填写并保存「{mail.LABEL['host']}」和「{mail.LABEL['from_addr']}」,再发测试邮件"}
|
||||
msg = Message(title="邮件服务测试", ok=True, lines=[("说明", "这是一封测试邮件。收到即表示邮件服务配置正确")])
|
||||
outcome = await mail.send(cfg, [to], msg, label="测试邮件", retry=False)
|
||||
if outcome.ok:
|
||||
return {"ok": True, "message": f"{mail.HANDED_OFF}(发到 {to}),是否送达以邮件服务器为准,请到收件箱确认"}
|
||||
return {"ok": False, "message": outcome.error or "发送失败"}
|
||||
|
||||
|
||||
@router.get("/settings/judge/spend")
|
||||
async def get_judge_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""今天(本地日期)结论句裁判花了多少,设置页放在每日上限旁边。只读:计数只有裁判自己记。
|
||||
|
||||
+326
-5
@@ -1,10 +1,331 @@
|
||||
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2)。
|
||||
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读,可以嵌进门户)。
|
||||
|
||||
读分享页的接口不用登录,只认请求头里的分享 key(中间件把 /api/shares/ 当公开前缀,第 3 波 W3-d 加);建、撤销分享在
|
||||
/api/apps/{id}/shares 下,要应用的 manage 权限。第 3 波 W3-d 填。
|
||||
为什么要有:会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上——想把一块看板放进别的门户,只能让每个看的人都开账号,
|
||||
而且门户和 AgentLab 必须同站(同站门户嵌整站见 deploy/agentlab.env 的 AGENTLAB_FRAME_ANCESTORS,C3)。分享链接是跨站嵌入
|
||||
唯一走得通的办法,所以做;但它等于把数据交出了授权范围,所以每一步都往窄处取:
|
||||
- 管理员开关 apps.share_links,缺省关;关掉时已有的链接一并打不开(403 share_links_off),撤销照常能做;
|
||||
- 只有应用的 manage 权限能建、能撤销;个人应用(personal.not_shareable)、没发布的、归档了的不能分享;
|
||||
- 到期必填,最长 EXPIRES_MAX_DAYS 天;撤销、到期之后打开回 410,写明是撤销了还是到期了、什么时候;
|
||||
- 只读:分享页只解析定时出数(latest_sealed)的视图,按需视图、实例、待办、发起按钮、记录列表换成写明原因的说明,从不
|
||||
发起运行(apps/resolve.py 的 share 旁路);看到的就是使用者看到的那份遮罩视图(视图对所有人都遮);
|
||||
- 出处不走在线的证据接口(那等于给匿名的人一个按运行读证据的身份):分享页给「下载报告」,GET /api/shares/report.html 只认
|
||||
这条分享此刻解析出来的那几次运行,内部用的就是报告导出 export_report_html(同样遮过,文件自己离线点得开出处);
|
||||
- key 放在链接的 # 后面(/s#<key>):打开页面时不发给服务器、不进代理日志和 Referer;读的接口只认请求头 X-AgentLab-Share,
|
||||
地址里带的一概不认。库里只存 key 的 sha256 和开头一截;
|
||||
- 建、撤销写审计(app.share_create / app.share_revoke);打开只计数(open_count、last_opened_at)。
|
||||
|
||||
接口:
|
||||
- POST / GET /api/apps/{app_id}/shares、DELETE /api/apps/{app_id}/shares/{share_id}:登录即可进来,接口里要应用的 manage;
|
||||
- GET /api/shares/page、GET /api/shares/report.html:公开路由(中间件把 /api/shares/ 当公开前缀,不用登录、不看会话),
|
||||
只认分享 key。
|
||||
|
||||
都不放给访问密钥(不在 auth/keys.KEY_ROUTES 里,门禁一律 403):分享是给人看的页面,集成要拉数走视图数据接口。
|
||||
安全头:分享页 /s 用嵌入白名单 AGENTLAB_EMBED_ORIGINS 和 Referrer-Policy: no-referrer(web_security.py);这一条分享没有允许
|
||||
嵌入(embed_allowed 为假)时,页面在别的页面的 iframe 里不显示内容(服务端在 /s 上只能按全站的白名单发头:那一刻还不知道是
|
||||
哪一条分享,key 不会发到服务器)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
router = APIRouter(prefix="/api/shares", tags=["shares"])
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.coded import CodedHTTPException
|
||||
from app.api.deps import Principal, actor_of, require_role
|
||||
from app.db.base import get_session, utcnow
|
||||
from app.db.models_apps import App, AppVersion, ViewShare
|
||||
|
||||
router = APIRouter(tags=["shares"])
|
||||
_manage = APIRouter(prefix="/api/apps")
|
||||
_public = APIRouter(prefix="/api/shares")
|
||||
|
||||
#: 读接口认的请求头
|
||||
SHARE_HEADER = "X-AgentLab-Share"
|
||||
#: 到期最长多少天
|
||||
EXPIRES_MAX_DAYS = 90
|
||||
#: key 的随机字节数、库里存的开头几位
|
||||
KEY_BYTES = 32
|
||||
PREFIX_LEN = 6
|
||||
#: 分享页的地址(key 接在 # 后面)
|
||||
SHARE_PATH = "/s"
|
||||
SHARE_FORMAT = "agentlab.share/1"
|
||||
|
||||
SHARE_LINKS_OFF = "share_links_off"
|
||||
SHARE_KEY_MISSING = "share_key_missing"
|
||||
SHARE_NOT_FOUND = "share_not_found"
|
||||
SHARE_REVOKED = "share_revoked"
|
||||
SHARE_EXPIRED = "share_expired"
|
||||
SHARE_GONE = "share_gone"
|
||||
SHARE_EXPIRES_INVALID = "share_expires_invalid"
|
||||
SHARE_NO_SEALED_VIEW = "share_no_sealed_view"
|
||||
SHARE_RUN_NOT_SHARED = "share_run_not_shared"
|
||||
SHARE_ID_NOT_FOUND = "share_id_not_found"
|
||||
|
||||
LINKS_OFF_CREATE = "管理员没有打开分享链接:请管理员在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」"
|
||||
LINKS_OFF_OPEN = "管理员已关闭分享链接,这条链接暂时打不开"
|
||||
|
||||
|
||||
def key_hash(key: str) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def _when(moment: datetime | None) -> str:
|
||||
if moment is None:
|
||||
return ""
|
||||
aware = moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||||
return aware.astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _aware(moment: datetime) -> datetime:
|
||||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def state_of(row: ViewShare, now: datetime | None = None) -> str:
|
||||
"""active 能打开;revoked 撤销了;expired 过了到期时间。"""
|
||||
if row.revoked_at is not None:
|
||||
return "revoked"
|
||||
if _aware(row.expires_at) <= (now or utcnow()):
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def share_out(row: ViewShare, titles: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""一条分享的接口形状(typesApp.ts 的 ViewShareRow):key 不回(只在建的时候给一次),给开头一截认是哪一条。"""
|
||||
iso = lambda m: _aware(m).isoformat() if m else None # noqa: E731
|
||||
return {"id": row.id, "prefix": row.prefix, "app_id": row.app_id, "page_id": row.page_id,
|
||||
"page_title": (titles or {}).get(row.page_id) or row.page_id, "mode": row.mode,
|
||||
"app_version": row.app_version, "embed_allowed": row.embed_allowed, "created_by": row.created_by,
|
||||
"created_by_name": row.created_by_name, "created_at": iso(row.created_at),
|
||||
"expires_at": iso(row.expires_at), "revoked_at": iso(row.revoked_at), "revoked_by": row.revoked_by,
|
||||
"open_count": row.open_count, "last_opened_at": iso(row.last_opened_at), "state": state_of(row)}
|
||||
|
||||
|
||||
async def _enabled(session: AsyncSession) -> bool:
|
||||
from app.api.settings import apps_settings
|
||||
|
||||
return bool((await apps_settings(session)).get("share_links"))
|
||||
|
||||
|
||||
def _titles(spec: dict[str, Any] | None) -> dict[str, str]:
|
||||
return {str(p.get("id")): str(p.get("title") or p.get("id")) for p in (spec or {}).get("pages") or []
|
||||
if isinstance(p, dict) and p.get("id")}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 建、列、撤销(要应用的 manage)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ShareIn(BaseModel):
|
||||
page_id: str
|
||||
mode: Literal["latest", "fixed"] = "latest"
|
||||
#: 到期时间,必填(不给回 422 share_expires_invalid);不带时区的按 UTC
|
||||
expires_at: datetime | None = None
|
||||
embed_allowed: bool = False
|
||||
|
||||
|
||||
async def _managed(session: AsyncSession, app_id: str, x_actor: str | None) -> tuple[App, Principal]:
|
||||
from app.api.apps import _app, _principal
|
||||
|
||||
principal = _principal(x_actor)
|
||||
app, _ = await _app(session, app_id, principal, "manage")
|
||||
return app, principal
|
||||
|
||||
|
||||
@_manage.post("/{app_id}/shares", status_code=201)
|
||||
async def create_share(app_id: str, payload: ShareIn, session: AsyncSession = Depends(get_session),
|
||||
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
"""建一条分享链接:要应用的 manage、管理员开了 apps.share_links。key 只在这里交回一次(path 是 /s#<key>,url 是拼上
|
||||
对外访问地址的完整链接,没设对外访问地址时为 null,界面按当前页面的地址拼)。"""
|
||||
from app.api.apps import _current_version, _refuse_archived, _refuse_personal
|
||||
from app.apps.audit import record
|
||||
from app.apps.resolve import SharedView, share_runs, sealed_views
|
||||
from app.apps.tree import page_of
|
||||
from app.core import public_url
|
||||
|
||||
app, principal = await _managed(session, app_id, x_actor)
|
||||
if not await _enabled(session):
|
||||
raise CodedHTTPException(403, LINKS_OFF_CREATE, SHARE_LINKS_OFF)
|
||||
_refuse_personal(app, "建分享链接")
|
||||
_refuse_archived(app, "建分享链接")
|
||||
version = await _current_version(session, app)
|
||||
now = utcnow()
|
||||
if payload.expires_at is None:
|
||||
raise CodedHTTPException(422, f"分享链接必须设到期时间,最长 {EXPIRES_MAX_DAYS} 天", SHARE_EXPIRES_INVALID,
|
||||
field="expires_at")
|
||||
expires = _aware(payload.expires_at)
|
||||
if expires <= now + timedelta(minutes=1):
|
||||
raise CodedHTTPException(422, "到期时间要在现在之后", SHARE_EXPIRES_INVALID, field="expires_at")
|
||||
# 留一小时的余量:界面按本机时间算「90 天后」,本机比服务端快一点不该被拒
|
||||
if expires > now + timedelta(days=EXPIRES_MAX_DAYS, hours=1):
|
||||
raise CodedHTTPException(422, f"分享链接最长 {EXPIRES_MAX_DAYS} 天到期,请选更早的到期时间",
|
||||
SHARE_EXPIRES_INVALID, field="expires_at")
|
||||
spec = version.spec or {}
|
||||
if page_of(spec, payload.page_id) is None:
|
||||
raise CodedHTTPException(404, f"应用的当前版本里没有页面「{payload.page_id}」", "app_page_not_found")
|
||||
if not sealed_views(spec, payload.page_id):
|
||||
raise CodedHTTPException(409, "这一页没有定时出数的视图(取最近一次封存运行的视图):分享链接只显示这种视图,"
|
||||
"分享出去什么都看不到", SHARE_NO_SEALED_VIEW)
|
||||
runs: dict[str, str | None] | None = None
|
||||
if payload.mode == "fixed":
|
||||
found = await share_runs(session, spec, payload.page_id, SharedView(mode="latest", key=""))
|
||||
runs = {vid: (run.id if run is not None else None) for vid, run in found.items()}
|
||||
if not any(runs.values()):
|
||||
raise CodedHTTPException(409, "这一页定时出数的视图都还没有封存的运行,没有可以固定的数:先用「始终最新」,"
|
||||
"或者等第一次出数之后再建", SHARE_NO_SEALED_VIEW)
|
||||
key = secrets.token_urlsafe(KEY_BYTES)
|
||||
row = ViewShare(key_hash=key_hash(key), prefix=key[:PREFIX_LEN], app_id=app.id, page_id=payload.page_id,
|
||||
mode=payload.mode, app_version=version.version, runs=runs, embed_allowed=payload.embed_allowed,
|
||||
created_by=actor_of(x_actor), created_by_name=principal.display_name or actor_of(x_actor),
|
||||
created_at=now, expires_at=expires)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
await record(session, action="app.share_create", subject_kind="view_share", subject_id=row.id,
|
||||
actor=actor_of(x_actor), data={"app_id": app.id, "page_id": payload.page_id, "mode": payload.mode,
|
||||
"app_version": version.version, "expires_at": expires.isoformat(),
|
||||
"embed_allowed": payload.embed_allowed, "prefix": row.prefix})
|
||||
await session.commit()
|
||||
path = f"{SHARE_PATH}#{key}"
|
||||
base = public_url.public_base_url()
|
||||
return {**share_out(row, _titles(spec)), "key": key, "path": path, "url": f"{base}{path}" if base else None}
|
||||
|
||||
|
||||
@_manage.get("/{app_id}/shares")
|
||||
async def list_shares(app_id: str, session: AsyncSession = Depends(get_session),
|
||||
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
"""这个应用的分享链接(新的在前)。enabled 是管理员开没开(关着时界面不给新建,已有的照列、能撤销);embed_origins 是部署
|
||||
配没配允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS);max_days 是到期最长几天。"""
|
||||
from app.api.apps import _previous
|
||||
from app.core.config import settings
|
||||
|
||||
app, _ = await _managed(session, app_id, x_actor)
|
||||
rows = list((await session.execute(select(ViewShare).where(ViewShare.app_id == app.id)
|
||||
.order_by(ViewShare.created_at.desc()))).scalars())
|
||||
version = await _previous(session, app)
|
||||
titles = _titles(version.spec if version is not None else None)
|
||||
return {"enabled": await _enabled(session), "embed_origins": bool(settings.embed_origins),
|
||||
"max_days": EXPIRES_MAX_DAYS, "items": [share_out(r, titles) for r in rows]}
|
||||
|
||||
|
||||
@_manage.delete("/{app_id}/shares/{share_id}")
|
||||
async def revoke_share(app_id: str, share_id: str, session: AsyncSession = Depends(get_session),
|
||||
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
"""撤销:之后打开回 410「链接已撤销」。撤销过的再撤销照旧交回它(不重复记审计)。管理员关了开关也能撤销。"""
|
||||
from app.apps.audit import record
|
||||
|
||||
app, _ = await _managed(session, app_id, x_actor)
|
||||
row = await session.get(ViewShare, share_id)
|
||||
if row is None or row.app_id != app.id:
|
||||
raise CodedHTTPException(404, "分享链接不存在,可能已被删除", SHARE_ID_NOT_FOUND)
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at, row.revoked_by = utcnow(), actor_of(x_actor)
|
||||
await record(session, action="app.share_revoke", subject_kind="view_share", subject_id=row.id,
|
||||
actor=actor_of(x_actor), data={"app_id": app.id, "page_id": row.page_id, "prefix": row.prefix})
|
||||
await session.commit()
|
||||
return share_out(row)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 打开(公开,只认请求头里的分享 key)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _share_principal(row: ViewShare) -> Principal:
|
||||
"""分享身份:不是任何一个人。按使用者算(role=user),用户名为空:审计里不冒充谁,遮罩和使用者看到的一样。"""
|
||||
from app.auth import current_mode
|
||||
|
||||
return Principal(username=None, role="user", display_name=f"分享链接 {row.prefix}", verified=False,
|
||||
mode=current_mode(), via="share")
|
||||
|
||||
|
||||
async def _opened(session: AsyncSession, key: str | None) -> tuple[ViewShare, App, AppVersion]:
|
||||
"""按请求头里的 key 认出分享,核对开关、撤销、到期、应用还在不在,交回 (分享, 应用, 要解析的应用版本)。"""
|
||||
from app.api.apps import _previous
|
||||
|
||||
if not key or not key.strip():
|
||||
raise CodedHTTPException(400, "缺少分享 key:请用完整的分享链接打开(链接里 # 后面的那一段)", SHARE_KEY_MISSING)
|
||||
row = (await session.execute(select(ViewShare).where(ViewShare.key_hash == key_hash(key.strip())))).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise CodedHTTPException(404, "链接无效:可能复制得不完整,请向分享的人确认链接", SHARE_NOT_FOUND)
|
||||
if not await _enabled(session):
|
||||
raise CodedHTTPException(403, LINKS_OFF_OPEN, SHARE_LINKS_OFF)
|
||||
state = state_of(row)
|
||||
if state == "revoked":
|
||||
raise CodedHTTPException(410, f"链接已撤销({_when(row.revoked_at)}):请向分享的人要新的链接", SHARE_REVOKED)
|
||||
if state == "expired":
|
||||
raise CodedHTTPException(410, f"链接已到期({_when(row.expires_at)}):请向分享的人要新的链接", SHARE_EXPIRED)
|
||||
app = await session.get(App, row.app_id)
|
||||
if app is None or app.status == "archived" or getattr(app, "scope", "shared") == "personal" \
|
||||
or not app.published_version:
|
||||
raise CodedHTTPException(410, "分享的应用已归档或不再发布,这条链接打不开了", SHARE_GONE)
|
||||
version = None
|
||||
if row.mode == "fixed" and row.app_version:
|
||||
version = (await session.execute(select(AppVersion).where(
|
||||
AppVersion.app_id == app.id, AppVersion.version == row.app_version))).scalar_one_or_none()
|
||||
version = version or await _previous(session, app)
|
||||
if version is None:
|
||||
raise CodedHTTPException(410, "分享的应用已归档或不再发布,这条链接打不开了", SHARE_GONE)
|
||||
return row, app, version
|
||||
|
||||
|
||||
def _shared(row: ViewShare) -> Any:
|
||||
from app.apps.resolve import SharedView
|
||||
|
||||
return SharedView(mode=row.mode, key=row.id, runs=dict(row.runs or {}) if row.mode == "fixed" else None)
|
||||
|
||||
|
||||
@_public.get("/page")
|
||||
async def share_page(session: AsyncSession = Depends(get_session),
|
||||
share_key: str | None = Header(default=None, alias=SHARE_HEADER)) -> dict[str, Any]:
|
||||
"""分享页(agentlab.share/1):{format, share: {页脚要的:谁分享、什么时候、什么时候到期、能不能嵌入、模式}, view:
|
||||
agentlab.view/1(share 旁路解析), downloads: [{view, run_id, reports, as_of_text}](下载报告用)}。打开一次计一次数。"""
|
||||
from app.apps.resolve import ViewRefused, resolve_page
|
||||
|
||||
row, app, version = await _opened(session, share_key)
|
||||
try:
|
||||
view = await resolve_page(session, app, page_id=row.page_id, principal=_share_principal(row), role=None,
|
||||
version=version, share=_shared(row))
|
||||
except ViewRefused as e:
|
||||
if e.code == "app_page_not_found":
|
||||
raise CodedHTTPException(410, "分享的页面在应用的当前版本里已经没有了,这条链接打不开了", SHARE_GONE) from e
|
||||
raise CodedHTTPException(e.status_code, e.message, e.code or SHARE_GONE) from e
|
||||
await session.execute(update(ViewShare).where(ViewShare.id == row.id).values(
|
||||
open_count=ViewShare.open_count + 1, last_opened_at=utcnow()))
|
||||
await session.commit()
|
||||
downloads = (view.pop("share", None) or {}).get("runs") or []
|
||||
return {"format": SHARE_FORMAT,
|
||||
"share": {"mode": row.mode, "created_by": row.created_by_name or row.created_by,
|
||||
"created_at": _aware(row.created_at).isoformat(), "expires_at": _aware(row.expires_at).isoformat(),
|
||||
"embed_allowed": row.embed_allowed},
|
||||
"view": view, "downloads": downloads}
|
||||
|
||||
|
||||
@_public.get("/report.html")
|
||||
async def share_report(run: str = Query(...), report: str | None = None, session: AsyncSession = Depends(get_session),
|
||||
share_key: str | None = Header(default=None, alias=SHARE_HEADER)) -> Any:
|
||||
"""下载报告:只认这条分享此刻解析出来的那几次运行(apps/resolve.share_runs),别的运行 id 一律 404。内部就是报告导出
|
||||
(api/evidence.export_report_html:同样遮过,报告文件自己离线点得开出处;导出照常记 report.exported 审计,导出人记成
|
||||
分享身份——用户名为空,不冒充谁)。"""
|
||||
from app.api.deps import reset_principal, set_principal
|
||||
from app.api.evidence import export_report_html
|
||||
from app.apps.resolve import share_runs
|
||||
|
||||
row, _, version = await _opened(session, share_key)
|
||||
found = await share_runs(session, version.spec or {}, row.page_id, _shared(row))
|
||||
if run not in {r.id for r in found.values() if r is not None}:
|
||||
raise CodedHTTPException(404, "这次运行不在这条分享链接里", SHARE_RUN_NOT_SHARED)
|
||||
token = set_principal(_share_principal(row))
|
||||
try:
|
||||
return await export_report_html(run, report)
|
||||
finally:
|
||||
reset_principal(token)
|
||||
|
||||
|
||||
router.include_router(_manage, dependencies=[require_role("user")])
|
||||
router.include_router(_public)
|
||||
|
||||
+142
-8
@@ -12,6 +12,14 @@
|
||||
- instance:实例自己的那次运行。
|
||||
|
||||
印章(截至 / 口径 / 出具档位 / 来源)由这里算;定时任务改跑了新版本而应用还钉在旧版本这类错位照实写进 notes。
|
||||
|
||||
分享链接(docs/design/next-round/04-对外集成与触达.md C2,api/shares.py)是解析时的另一条旁路(resolve_page 的 share):
|
||||
拿到链接、不用登录的人看到的就是使用者看到的那份遮罩视图,但只有定时出数的那部分——
|
||||
- 只解析 latest_sealed 视图(fixed 模式取建分享时存下的那几次运行 id,SharedView.runs);按需、实例、个人应用的探索视图换成
|
||||
写明原因的空态(SHARE_SEALED_ONLY),从不发起运行(按需视图不自动发起,因为它根本不按按需解析);
|
||||
- 流程组件(入口按钮、表单、实例、进度、待办、会签、生效记录、记录列表)换成写明原因的说明块(SHARE_PLACEHOLDER),报告
|
||||
组件也换成说明(正文和出处在「下载报告」里:分享页不开在线证据,那等于给匿名的人一个按运行读证据的身份);
|
||||
- 印章照常算,界面上常显;页面缓存的键另加分享的标识。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,6 +52,18 @@ PAGE_CACHE_SIZE = 256
|
||||
|
||||
#: 按新口径补算的运行来源(engine/origin.py)
|
||||
BACKFILL_ORIGIN = "app_backfill"
|
||||
#: 「最近一次封存」认哪几种来源的正式运行:latest_sealed 视图、趋势、分享链接(latest,以及建 fixed 时取的那几次)、视图
|
||||
#: 数据接口、补算判断「新版本算过没有」都经 sealed_runs,这里改一处都跟上。按白名单认,以后加的来源默认不算,要算的在这里
|
||||
#: 登记(tests/test_shares.py 核对 ORIGIN_KINDS 每种都归了类)。算的:定时、手动的正式运行、应用的入口和按需视图、入站
|
||||
#: 钩子、直接调接口;加来源这一列之前的老运行(origin_kind 为空)也算。
|
||||
SEALED_ORIGINS = ("launcher", "schedule", "app_entry", "app_view", "hook", "api")
|
||||
#: 不算的,各有原因:
|
||||
#: - app_backfill:按新口径补算的是现在才算的历史期,不是「最近一次」;趋势另经 trend_runs 按它对应的输入期排进去;
|
||||
#: - ledger_issue:问数据的答案按已治理口径正式出具(docs/design/next-round/02-口径账本.md §5),参数是从某人的提问里
|
||||
#: 绑出来的。取到它,看板、趋势、分享链接就悄悄换成了那个人问的那组数;
|
||||
#: - 其余是探索运行(问数据、画布试跑、试运行、个人应用的刷新,评测、口径对账、口径召回),本来就不是正式运行。
|
||||
NOT_SEALED_ORIGINS = (BACKFILL_ORIGIN, "ledger_issue", "chat", "canvas", "trial", "personal_app", "eval", "reconcile",
|
||||
"recall")
|
||||
|
||||
VIEW_REFUSED = "app_view_refused"
|
||||
DAILY_LIMIT = "app_view_daily_limit"
|
||||
@@ -76,6 +96,36 @@ _PERIOD = re.compile(r"(period|week|month|quarter|year|date|day|期|周|月|季|
|
||||
_DIGIT = re.compile(r"\d")
|
||||
|
||||
|
||||
#: 分享链接里(share 旁路)不是定时出数的视图、流程组件、报告写什么
|
||||
SHARE_SEALED_ONLY = "分享链接只显示定时出数的视图:这一块的数要打开应用才看得到"
|
||||
SHARE_PLACEHOLDER = {
|
||||
"entry_button": "分享链接是只读的,不能在这里发起",
|
||||
"form": "分享链接是只读的,不能在这里换参数",
|
||||
"instance_list": "分享链接不列实例:要看实例请打开应用",
|
||||
"instance_header": "分享链接不显示实例:要看实例请打开应用",
|
||||
"instance_timeline": "分享链接不显示实例进度:要看请打开应用",
|
||||
"task_list": "分享链接不列待办:待办只给派给的人,请打开应用处理",
|
||||
"task_form": "分享链接不显示待办:待办只给派给的人,请打开应用处理",
|
||||
"signoffs": "分享链接不显示会签进度:要看请打开应用",
|
||||
"action_status": "分享链接不显示写入外部系统的记录:要看请打开应用",
|
||||
"records_list": "分享链接不列登记的记录:要看请打开应用",
|
||||
"report": "报告正文和每个数的出处在「下载报告」里:下载的文件离线也能点开出处",
|
||||
}
|
||||
SHARE_FIXED_NEW_VIEW = "这个视图是建分享之后才加的:固定在当时那几次运行的分享链接里没有它"
|
||||
SHARE_FIXED_EMPTY = "建分享时这个视图还没有封存的运行"
|
||||
SHARE_FIXED_GONE = "建分享时固定的那次运行已经不在了"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SharedView:
|
||||
"""分享链接的解析方式(api/shares.py 建好交进来)。mode:latest 始终取最近一次封存;fixed 取 runs 里存的运行
|
||||
({视图 id: 运行 id,建分享时还没有封存运行的是 None})。key 进页面缓存的键(分享的 id)。"""
|
||||
|
||||
mode: str
|
||||
key: str
|
||||
runs: dict[str, str | None] | None = None
|
||||
|
||||
|
||||
class ViewRefused(Exception):
|
||||
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
@@ -100,8 +150,8 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra
|
||||
"""钉住版本(同一 graph_hash)最近 n 次成功且已封存的正式运行,新的在前。
|
||||
|
||||
sealed_now 认的是「这一次结束时封的」:失败后接着跑完、新封存还没补上的那一刻,带着的是旧封存,不算。
|
||||
按新口径补算的运行(app_backfill)不算:它们是现在才算的历史期,「最近一次」不能取到它们;趋势另经 trend_runs
|
||||
按它们对应的输入期排进去。"""
|
||||
来源只认 SEALED_ORIGINS(和老运行):按新口径补算的(app_backfill)、问数据按口径出具的(ledger_issue)都不是
|
||||
「最近一次」,原因见 NOT_SEALED_ORIGINS。"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.scheduler.auto_compare import sealed_now
|
||||
@@ -110,7 +160,7 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra
|
||||
return []
|
||||
query = select(Run).where(Run.workflow_id == workflow_id, Run.version == version, Run.run_class == "formal",
|
||||
Run.status == "succeeded", Run.version_hash == graph_hash,
|
||||
or_(Run.origin_kind.is_(None), Run.origin_kind != BACKFILL_ORIGIN))
|
||||
or_(Run.origin_kind.is_(None), Run.origin_kind.in_(SEALED_ORIGINS)))
|
||||
if isinstance(origin, str) and origin.startswith("schedule:"):
|
||||
query = query.where(Run.schedule_id == origin[len("schedule:"):])
|
||||
rows = list((await session.execute(query.order_by(Run.finished_at.desc(), Run.created_at.desc())
|
||||
@@ -567,6 +617,8 @@ class _Page:
|
||||
page: dict[str, Any] = field(default_factory=dict)
|
||||
#: 蓝图的试运行(trial 旁路):草稿里所有视图来源都解析到这次运行,流程组件也对着它
|
||||
trial: Run | None = None
|
||||
#: 分享链接(share 旁路):只解析定时出数的视图,流程组件换成说明
|
||||
share: SharedView | None = None
|
||||
pins: dict[str, PinState] = field(default_factory=dict)
|
||||
sources: dict[str, Source] = field(default_factory=dict)
|
||||
problems: list[dict[str, Any]] = field(default_factory=list)
|
||||
@@ -606,13 +658,16 @@ async def _backfilled(session: AsyncSession, app_id: str) -> int:
|
||||
async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, principal: Principal, role: str | None,
|
||||
instance: Run | None = None, params: dict[str, Any] | None = None, draft: bool = False,
|
||||
version: AppVersion | None = None, actor: str | None = None,
|
||||
trial: Run | None = None) -> dict[str, Any]:
|
||||
trial: Run | None = None, share: SharedView | None = None) -> dict[str, Any]:
|
||||
"""解析一页,返回 agentlab.view/1。draft 为真时按草稿解析:不发起任何运行,视图取钉住版本已有的封存运行。
|
||||
|
||||
trial 是蓝图的一次试运行(docs/design/normal-user-apps.md N4,只和 draft 一起用):草稿里所有视图来源(最近封存、
|
||||
按需、本实例)都解析到这次运行,流程组件(实例进度、待办)也对着它;印章的来源写「试运行」、trial 为真、说明里写
|
||||
「这不是正式结果」;趋势只有这一个点。不改 agentlab.app/1 的视图来源,只是解析时的旁路。
|
||||
|
||||
share 是分享链接(见模块说明,只和发布版一起用:草稿、个人应用、试运行都不能分享):只解析定时出数的视图,流程组件和
|
||||
报告换成写明原因的说明,从不发起运行;out 里另给 share.runs(各视图取到的运行、有哪几份报告可下载)。
|
||||
|
||||
个人应用(N7)没有应用版本:草稿就是在用的那一份,不按草稿预览时也解析它(version 为空)。作者隐藏了的组件(hidden)
|
||||
不下发;按草稿解析(编辑状态)时照常解析、带上 hidden,界面画成半透明。"""
|
||||
import copy
|
||||
@@ -625,8 +680,11 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
|
||||
if page is None:
|
||||
raise ViewRefused(404, f"应用里没有页面「{page_id}」", "app_page_not_found")
|
||||
trial = trial if draft else None
|
||||
if share is not None and (draft or personal or version is None or instance is not None):
|
||||
raise ViewRefused(409, "分享链接只解析已发布的共享应用", "personal.not_shareable" if personal else VIEW_REFUSED)
|
||||
ctx = _Page(app=app, spec=spec, version=None if draft else version, principal=principal, role=role,
|
||||
instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial)
|
||||
instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial,
|
||||
share=share)
|
||||
ctx.pins = await load_pins(session, spec)
|
||||
# 试运行的钉住项本来就没有版本(还没发布),不是「需要升级」
|
||||
upgrade = needs_upgrade(ctx.pins) if trial is None else False
|
||||
@@ -644,9 +702,10 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
|
||||
tuple((a, p.workflow.published_version if p.workflow else None, p.retired)
|
||||
for a, p in sorted(ctx.pins.items())),
|
||||
role, principal.username, principal.role, await _mask_fingerprint(session),
|
||||
await _backfilled(session, app.id))
|
||||
await _backfilled(session, app.id), ("share", share.key) if share is not None else None)
|
||||
# 分享链接里流程组件都换成了说明,页面上没有随人、随时变的东西:照样进缓存
|
||||
cacheable = (not draft and instance is None and all(s.state in ("ok", "empty") for s in ctx.sources.values())
|
||||
and not _dynamic(page))
|
||||
and (share is not None or not _dynamic(page)))
|
||||
if cacheable and key in _PAGE_CACHE:
|
||||
_PAGE_CACHE.move_to_end(key)
|
||||
return copy.deepcopy(_PAGE_CACHE[key])
|
||||
@@ -654,7 +713,8 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
|
||||
# 会有(Source.message),不进 problems——进了,页面顶上挂一块「无法显示」,使用者以为应用坏了
|
||||
for source in ctx.sources.values():
|
||||
await _load(session, source)
|
||||
for entry in spec.get("entries") or []:
|
||||
# 分享链接里不能发起:入口的表单不下发
|
||||
for entry in (spec.get("entries") or []) if share is None else []:
|
||||
form = resolve_form(spec, entry.get("form"))
|
||||
if form is not None and entry.get("id"):
|
||||
ctx.forms[str(entry["id"])] = form
|
||||
@@ -673,6 +733,7 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
|
||||
"problems": ctx.problems,
|
||||
**({"trial": {"run_id": trial.id, "status": trial.status,
|
||||
"status_text": STATUS_TEXT.get(trial.status, trial.status)}} if trial is not None else {}),
|
||||
**({"share": {"mode": share.mode, "runs": _share_runs_out(ctx)}} if share is not None else {}),
|
||||
}
|
||||
if cacheable:
|
||||
_PAGE_CACHE[key] = copy.deepcopy(out)
|
||||
@@ -681,6 +742,49 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
|
||||
return out
|
||||
|
||||
|
||||
def _share_runs_out(ctx: _Page) -> list[dict[str, Any]]:
|
||||
"""分享页上「下载报告」用:各视图取到的、成功封存的那次运行,带有哪几份没被改过的报告(节点 id)。同一次运行只列一次。"""
|
||||
from app.api.evidence import _reports
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for vid, source in ctx.sources.items():
|
||||
if source.state != "ok" or source.run is None or source.sealed is None or source.run.id in seen:
|
||||
continue
|
||||
seen.add(source.run.id)
|
||||
reports = [r.node_id for r in _reports(source.sealed) if r.hash_ok is not False and r.doc is not None]
|
||||
out.append({"view": vid, "run_id": source.run.id, "reports": reports,
|
||||
"as_of_text": (source.stamp or {}).get("as_of_text")})
|
||||
return out
|
||||
|
||||
|
||||
async def share_runs(session: AsyncSession, spec: dict[str, Any], page_id: str, share: SharedView) -> dict[str, Run | None]:
|
||||
"""这条分享链接现在解析得到的运行:{视图 id: 运行}(只有这一页上定时出数的视图,取不到的是 None)。下载报告只认这几次
|
||||
(api/shares.py),和页面上 _source 的分享旁路同一个取法。"""
|
||||
page = page_of(spec, page_id)
|
||||
if page is None:
|
||||
return {}
|
||||
ctx = _Page(app=None, spec=spec, version=None, principal=Principal(username=None, role="user", via="share"),
|
||||
role=None, instance=None, params={}, draft=False, actor=None, page=page, share=share)
|
||||
ctx.pins = await load_pins(session, spec)
|
||||
out: dict[str, Run | None] = {}
|
||||
for vid in _views_of(page):
|
||||
if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") != "latest_sealed":
|
||||
continue
|
||||
source = await _source(session, ctx, vid)
|
||||
out[vid] = source.run if source.state == "ok" else None
|
||||
return out
|
||||
|
||||
|
||||
def sealed_views(spec: dict[str, Any], page_id: str) -> list[str]:
|
||||
"""这一页上取最近封存运行(定时出数)的视图:能不能建分享链接看它(一个都没有,分享出去什么都看不到)。"""
|
||||
page = page_of(spec, page_id)
|
||||
if page is None:
|
||||
return []
|
||||
return [vid for vid in _views_of(page)
|
||||
if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") == "latest_sealed"]
|
||||
|
||||
|
||||
_DYNAMIC = frozenset({"instance_list", "task_list", "instance_header", "instance_timeline", "task_form", "signoffs",
|
||||
"action_status", "entry_button", "records_list"})
|
||||
|
||||
@@ -724,6 +828,12 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source:
|
||||
return Source(id=vid, kind="", state="error", message=f"视图「{vid}」不存在")
|
||||
if ctx.trial is not None:
|
||||
return await _trial_source(session, ctx, vid, kind, src)
|
||||
if ctx.share is not None:
|
||||
if kind != "latest_sealed":
|
||||
# 分享链接只显示定时出数的视图:按需视图不发起、实例和探索视图不取
|
||||
return Source(id=vid, kind=kind, state="empty", message=SHARE_SEALED_ONLY)
|
||||
if ctx.share.mode == "fixed":
|
||||
return await _fixed_source(session, ctx, vid, src)
|
||||
state = ctx.pins.get(src.get("pin")) if kind != "instance" else None
|
||||
run: Run | None = None
|
||||
if kind == "instance":
|
||||
@@ -755,6 +865,21 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source:
|
||||
return source
|
||||
|
||||
|
||||
async def _fixed_source(session: AsyncSession, ctx: _Page, vid: str, src: dict[str, Any]) -> Source:
|
||||
"""fixed 模式的分享链接:取建分享时这个视图的那次运行(还得是成功封存的正式运行),之后封存的新运行不换上来。"""
|
||||
assert ctx.share is not None
|
||||
runs = ctx.share.runs or {}
|
||||
state = ctx.pins.get(src.get("pin"))
|
||||
if vid not in runs:
|
||||
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_NEW_VIEW)
|
||||
if runs[vid] is None:
|
||||
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_EMPTY)
|
||||
run = await session.get(Run, runs[vid])
|
||||
if run is None or run.status != "succeeded" or run.run_class != "formal":
|
||||
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_GONE)
|
||||
return Source(id=vid, kind="latest_sealed", state="ok", run=run, pin=state, origin=src.get("from"))
|
||||
|
||||
|
||||
async def _exploratory_source(session: AsyncSession, ctx: _Page, vid: str, state: PinState) -> Source:
|
||||
"""个人应用的视图(docs/design/normal-user-apps.md N7):这个应用、这个拥有者、这张图下最近一次成功封存的刷新
|
||||
(blueprints/personal.exploratory_runs)。只在个人应用里合法(应用检查报 app.view_source_personal):共享应用里写了的
|
||||
@@ -867,6 +992,10 @@ async def _node(session: AsyncSession, ctx: _Page, comp: dict[str, Any]) -> dict
|
||||
return None
|
||||
if comp.get("visible") and not await _visible(session, ctx, comp):
|
||||
return None
|
||||
if ctx.share is not None and (reason := SHARE_PLACEHOLDER.get(str(comp.get("type")))):
|
||||
# 分享链接是只读的:流程组件、报告换成一块写明原因的说明(前端照说明块画),不取实例、待办、报告正文
|
||||
return {"id": cid, "type": "notice", "props": {"text": reason, "tone": "info"},
|
||||
"share_placeholder": comp.get("type")}
|
||||
node: dict[str, Any] = {"id": cid, "type": comp.get("type"), "props": dict(comp.get("props") or {}),
|
||||
**({"hidden": True} if comp.get("hidden") else {})}
|
||||
entry = BY_NAME.get(str(comp.get("type")))
|
||||
@@ -1082,6 +1211,11 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r
|
||||
runs = await trend_runs(session, app_id, pin.pin.get("workflow_id"), pin.pin.get("version"),
|
||||
pin.pin.get("graph_hash"), origin, n)
|
||||
segments.append((int(pin.pin.get("version")), str(pin.pin.get("graph_hash")), runs))
|
||||
if ctx.share is not None and ctx.share.mode == "fixed" and source.run is not None:
|
||||
# 固定在建分享那一刻的分享链接:趋势也停在那一期,之后封存的不画进来
|
||||
cutoff = period_of(source.run)
|
||||
segments = [(v, h, [r for r in rows if cutoff is None or (period_of(r) or cutoff) <= cutoff])
|
||||
for v, h, rows in segments]
|
||||
series: list[dict[str, Any]] = []
|
||||
unit = ""
|
||||
for version, _, rows in segments:
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
和加这一层之前一模一样。
|
||||
开登录:
|
||||
- 会话 cookie → Principal(认证过的用户名、角色、组);没有会话是匿名(username=None),X-Actor 一律不看;
|
||||
- /api 下除了登录本身、健康检查、入站钩子,匿名一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底:
|
||||
- /api 下除了登录本身、健康检查、入站钩子、分享链接的读接口(/api/shares/,只认请求头里的分享 key,api/shares.py),匿名
|
||||
一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底:
|
||||
哪个路由忘了挂依赖,也不会对没登录的人敞开;
|
||||
- 接口说明(FastAPI 自带的 /docs、/redoc、/openapi.json)和 /api 一样拦:匿名 401,不到搭建者 403。它们是 FastAPI 自己
|
||||
挂的路由,挂不上路由级依赖,所以角色也在这里判。以前只拦 /api/ 开头的,开了登录接口全貌也对匿名的人敞开;
|
||||
@@ -38,6 +39,8 @@ _PUBLIC = frozenset({"/api/health", "/api/auth/me", "/api/auth/login", "/api/aut
|
||||
"/api/auth/oidc/callback", "/api/auth/jwks", "/api/auth/bootstrap"})
|
||||
#: 只认签名、不走会话的入站钩子(也不查 CSRF:调用方是外部系统,没有浏览器的 Origin)
|
||||
_HOOKS = "/api/hooks/"
|
||||
#: 分享链接的读接口(04 细案 C2):不用登录,接口里只认请求头 X-AgentLab-Share,会话有没有都不看。只有 GET:非 GET 照常查 CSRF
|
||||
_SHARES = "/api/shares/"
|
||||
#: FastAPI 自带的接口说明(main.py 里 FastAPI() 的缺省地址):开登录时要搭建者
|
||||
_DOCS = ("/docs", "/redoc", "/openapi.json")
|
||||
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
@@ -153,7 +156,7 @@ class AuthMiddleware:
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
return
|
||||
if (scope["type"] == "http" and is_api and not principal.username and path not in _PUBLIC
|
||||
and not path.startswith(_HOOKS)):
|
||||
and not path.startswith(_HOOKS) and not path.startswith(_SHARES)):
|
||||
await _reply(send, 401, "请先登录", AUTH_REQUIRED)
|
||||
return
|
||||
if scope["type"] == "http" and _is_docs(path) and (refused := refusal(principal, "builder")) is not None:
|
||||
|
||||
@@ -146,3 +146,37 @@ class ApiReceipt(Base):
|
||||
status: Mapped[str] = mapped_column(String(20))
|
||||
detail: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
|
||||
|
||||
class ViewShare(Base):
|
||||
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读,
|
||||
可以嵌进门户)。
|
||||
|
||||
链接写成 /s#<分享 key>:key 在 # 后面,打开页面时不发给服务器、不进访问日志和 Referer,页面读出来再放进请求头
|
||||
X-AgentLab-Share(api/shares.py)。库里只存 key 的 sha256(key_hash)和开头一截(prefix,认是哪一条),同访问密钥。
|
||||
|
||||
mode:latest 始终取最近一次封存;fixed 取建分享那一刻每个视图的运行(runs:{视图 id: 运行 id},还没有封存运行的是
|
||||
null),解析建分享时的应用版本(app_version)。到期必填、最长 90 天;撤销、到期之后打开回 410 并写明。建、撤销写审计;
|
||||
打开只计数(open_count、last_opened_at),不一次写一行。embed_allowed 为假时分享页不肯在别的页面的 iframe 里显示(服务端
|
||||
在 /s 上只能按全站的嵌入白名单 AGENTLAB_EMBED_ORIGINS 发头:打开页面那一刻还不知道是哪一条分享)。"""
|
||||
|
||||
__tablename__ = "view_shares"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||||
key_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
prefix: Mapped[str] = mapped_column(String(16))
|
||||
app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True)
|
||||
page_id: Mapped[str] = mapped_column(String(100))
|
||||
mode: Mapped[str] = mapped_column(String(20), default="latest") # latest | fixed
|
||||
app_version: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
runs: Mapped[dict[str, Any] | None] = mapped_column(default=None)
|
||||
embed_allowed: Mapped[bool] = mapped_column(default=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||
#: 建分享的人当时的显示名:分享页页脚写「由 X 分享」
|
||||
created_by_name: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
revoked_by: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||
open_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_opened_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""通知的发送核心(附录 C,BE-2 提供)。从 scheduler/webhook.py 抽出:四种格式、签名、发送、脱敏(core.py);
|
||||
webhook.py 照原名再导出,定时任务的通知存储不迁移(F14)。新对象(待办、实例、动作)的订阅和投递记录在
|
||||
service.py:渠道(notify_channels)、投递记录(notify_deliveries)。"""
|
||||
service.py:渠道(notify_channels)、投递记录(notify_deliveries)。渠道的第五种格式「邮件」走 mail.py(SMTP),订阅、
|
||||
投递记录、消息结构都和 Webhook 同一套,只是在这里按格式分到另一条发送路上。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -22,6 +23,10 @@ async def send(channel: Any, message: dict[str, Any]) -> Any:
|
||||
lines=[(str(k), str(v)) for k, v in message.get("lines") or []],
|
||||
links=[(str(k), str(v)) for k, v in message.get("links") or []],
|
||||
ok=bool(message.get("ok")))
|
||||
if channel.format == "email":
|
||||
from app.notify import mail
|
||||
|
||||
return await mail.deliver(channel, message, msg)
|
||||
if not url:
|
||||
return Outcome(False, None, 0, "渠道的地址无法解密(加密密钥可能已更换),请重新填写地址")
|
||||
if getattr(channel, "scope", None) == "user":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""通知的发送核心:四种格式、签名、发送、脱敏(从 scheduler/webhook.py 抽出,定时任务和待办、实例、动作共用一份)。
|
||||
|
||||
四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。配了签名密钥时:通用格式在请求头
|
||||
四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。通知渠道另有第五种「邮件」,发送在
|
||||
notify/mail.py(SMTP,不经这里的 HTTP 推送),消息结构(Message、text_of)共用这里的。配了签名密钥时:通用格式在请求头
|
||||
里带 HMAC-SHA256 签名,飞书、钉钉按各自平台的算法签(飞书放在请求体,钉钉放在地址上);企业微信的 key 本来就在
|
||||
地址里,没有另外的签名。入站钩子(app/process/hooks.py)验的也是通用格式这一套签名。
|
||||
|
||||
@@ -37,8 +38,11 @@ from app.db.base import utcnow
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FORMATS = ("generic", "feishu", "wecom", "dingtalk")
|
||||
#: 通知渠道能选的格式:Webhook 的四种,加上邮件(notify/mail.py,04 细案 B2,第五种格式)。定时任务的 Webhook 只有前四种
|
||||
#: (它只会发 HTTP),所以邮件不进 FORMATS:scheduler/webhook 再导出的、定时任务接口校验的都是那一份
|
||||
CHANNEL_FORMATS = (*FORMATS, "email")
|
||||
#: 格式在报错里的叫法,和界面上的选项一致
|
||||
FORMAT_LABEL = {"generic": "通用", "feishu": "飞书", "wecom": "企业微信", "dingtalk": "钉钉"}
|
||||
FORMAT_LABEL = {"generic": "通用", "feishu": "飞书", "wecom": "企业微信", "dingtalk": "钉钉", "email": "邮件"}
|
||||
#: 单次请求的超时(秒)
|
||||
TIMEOUT_S = 10.0
|
||||
#: 失败后再试 3 次,每次之前等这么久(秒)。4xx(429 除外)不重试:地址或参数错了,重试也一样
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""每天的待办汇总、访问密钥快到期:什么时候推(docs/design/next-round/04-对外集成与触达.md B3)。
|
||||
|
||||
调度循环每一拍调一次(scheduler/loop.process_due 的步骤列表);拼消息、推送在 notify/service(notify_digest、
|
||||
notify_key_expiring),推送放在后台(service.spawn):接收端挂住时超时加重试要几十秒,不能拖着这一拍别的步骤。
|
||||
|
||||
待办汇总(task.digest):
|
||||
- 每天到设置里的 notify.digest_at(缺省 09:00,按服务端所在时区)之后的第一拍,给订阅了 task.digest 的个人渠道的拥有者各发
|
||||
一条(没有待办的人不发);
|
||||
- 按日期在 settings 表里认领一行(键 STATE_PREFIX + "digest:<日期>",主键冲突就是已经认领过):同一天只发一次,重启了也不
|
||||
重发。认领之后推送失败不补发——通知本来就是至多一次,投递记录里照实记失败。停机错过的那一天不补;
|
||||
- 认领行只留最近 KEEP_DAYS 天;
|
||||
- 关登录时不发:「派给我的」要认得出是谁(关登录时人人等同管理员,待办箱里是全部待办),个人渠道本来也只挂在认证过的账号上。
|
||||
|
||||
访问密钥快到期(key.expiring):
|
||||
- 到期前 7 天、1 天各推一次(THRESHOLDS):个人访问密钥推给拥有者的个人渠道,服务账号的推给订阅了的全局渠道;
|
||||
- 去重靠审计:推之前先记一条 access_key.expiring(data.days 是哪一档),记过的不再推(进程里另记一份,省得每拍查审计);
|
||||
- 不推的:建的时候离到期已经不到这一档(自己设了短有效期,知道它快到期);轮换下来、24 小时后到期的旧密钥(新的那把已经
|
||||
在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了);关登录时的一概不推(访问密钥只在开了登录时
|
||||
生效)。
|
||||
|
||||
这两种状态(认领行、自动建过邮件渠道的人)存在 settings 表里,键都以 STATE_PREFIX 开头:它们是通知自己记的账,不是用户设置,
|
||||
GET /api/settings 不列、PUT 不收(api/settings.py)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import NotifyChannel, Setting, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: settings 表里通知自己记的账(不是用户设置)的键的开头
|
||||
STATE_PREFIX = "notify_state:"
|
||||
DIGEST_CLAIM = STATE_PREFIX + "digest:"
|
||||
#: 汇总的缺省发送时间(服务端所在时区)
|
||||
DEFAULT_AT = "09:00"
|
||||
#: 认领行留几天
|
||||
KEEP_DAYS = 7
|
||||
#: 访问密钥到期前几天各推一次
|
||||
THRESHOLDS = (7, 1)
|
||||
|
||||
_TIME = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
||||
#: 这个进程里已经认领(或者看到别人认领了)的日期:同一天不再去插认领行
|
||||
_claimed: set[str] = set()
|
||||
#: 这个进程里已经推过(或者审计里已经有)的 (密钥 id, 档)
|
||||
_noted: set[tuple[str, int]] = set()
|
||||
|
||||
|
||||
def time_ok(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(_TIME.match(value.strip()))
|
||||
|
||||
|
||||
def at_of(stored_notify: Any) -> tuple[int, int]:
|
||||
"""设置 notify 一组里的 digest_at → (时, 分);没填、存坏了按缺省。"""
|
||||
raw = stored_notify.get("digest_at") if isinstance(stored_notify, dict) else None
|
||||
match = _TIME.match(raw.strip()) if isinstance(raw, str) else None
|
||||
if match is None:
|
||||
match = _TIME.match(DEFAULT_AT)
|
||||
assert match is not None
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
async def _claim(day: str, now: datetime) -> bool:
|
||||
"""认领这一天的汇总:插一行,主键冲突就是已经有人认领过。顺带清掉 KEEP_DAYS 天以前的认领行。"""
|
||||
key = DIGEST_CLAIM + day
|
||||
async with SessionLocal() as session:
|
||||
if await session.get(Setting, key) is not None:
|
||||
return False
|
||||
session.add(Setting(key=key, value={"claimed_at": now.isoformat()}))
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
return False
|
||||
floor = DIGEST_CLAIM + (now.astimezone().date() - timedelta(days=KEEP_DAYS)).isoformat()
|
||||
await session.execute(delete(Setting).where(Setting.key.like(DIGEST_CLAIM + "%"), Setting.key < floor))
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def digest_due(now: datetime | None = None) -> list[str]:
|
||||
"""到点了就认领今天、给订阅了汇总的人各发一条(后台推)。交回这一拍要发给谁(用户 id),没到点、已经发过是空的。"""
|
||||
from app.api.settings import NOTIFY_GROUP
|
||||
from app.auth import current_mode
|
||||
from app.notify import service
|
||||
|
||||
now = now or utcnow()
|
||||
local = now.astimezone()
|
||||
day = local.date().isoformat()
|
||||
if day in _claimed or current_mode() == "off":
|
||||
return []
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Setting, NOTIFY_GROUP)
|
||||
if (local.hour, local.minute) < at_of(row.value if row is not None else None):
|
||||
return []
|
||||
claimed = await _claim(day, now)
|
||||
_claimed.add(day)
|
||||
if not claimed:
|
||||
return []
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(select(NotifyChannel.owner_id, NotifyChannel.events).where(
|
||||
NotifyChannel.scope == "user", NotifyChannel.enabled.is_(True),
|
||||
NotifyChannel.owner_id.is_not(None)))).all()
|
||||
owners = list(dict.fromkeys(owner for owner, events in rows if "task.digest" in (events or [])))
|
||||
for owner in owners:
|
||||
service.spawn(_digest_one(owner))
|
||||
if owners:
|
||||
logger.info("今天(%s)的待办汇总:发给 %d 个人", day, len(owners))
|
||||
return owners
|
||||
|
||||
|
||||
async def _digest_one(user_id: str) -> None:
|
||||
from app.notify import service
|
||||
|
||||
try:
|
||||
await service.notify_digest(user_id)
|
||||
except Exception: # noqa: BLE001 - 一个人的汇总出错不影响别人,待办本身也不受影响
|
||||
logger.exception("给用户 %s 发待办汇总失败", user_id)
|
||||
|
||||
|
||||
async def keys_due(now: datetime | None = None) -> list[str]:
|
||||
"""访问密钥快到期了的,按档推一次(见模块说明)。交回这一拍推了哪几把(密钥 id)。"""
|
||||
from app.auth import current_mode, personal_keys_enabled
|
||||
from app.auth.audit import record
|
||||
from app.auth.keys import SERVICE_KIND
|
||||
from app.db.models import AuditEvent
|
||||
from app.db.models_identity import AccessKey
|
||||
from app.notify import service
|
||||
|
||||
if current_mode() == "off":
|
||||
return []
|
||||
now = now or utcnow()
|
||||
horizon = now + timedelta(days=max(THRESHOLDS))
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(select(AccessKey, User).join(User, User.id == AccessKey.owner_user_id).where(
|
||||
AccessKey.revoked_at.is_(None), AccessKey.expires_at > now, AccessKey.expires_at <= horizon))).all()
|
||||
if not rows:
|
||||
return []
|
||||
ids = [key.id for key, _ in rows]
|
||||
replaced = set((await session.execute(select(AccessKey.rotated_from).where(
|
||||
AccessKey.rotated_from.in_(ids)))).scalars())
|
||||
out: list[str] = []
|
||||
for key, owner in rows:
|
||||
if owner.disabled or key.id in replaced:
|
||||
continue
|
||||
if owner.kind != SERVICE_KIND and not personal_keys_enabled():
|
||||
continue
|
||||
expires = service._utc(key.expires_at)
|
||||
left = expires - now
|
||||
days = next(d for d in sorted(THRESHOLDS) if left <= timedelta(days=d))
|
||||
created = service._utc(key.created_at) if key.created_at else None
|
||||
if created is not None and created > expires - timedelta(days=days):
|
||||
continue
|
||||
if (key.id, days) in _noted:
|
||||
continue
|
||||
async with SessionLocal() as session:
|
||||
seen = (await session.execute(select(AuditEvent.data).where(
|
||||
AuditEvent.action == "access_key.expiring", AuditEvent.subject_id == key.id))).scalars()
|
||||
if any(isinstance(d, dict) and d.get("days") == days for d in seen):
|
||||
_noted.add((key.id, days))
|
||||
continue
|
||||
await record(session, "access_key.expiring", "access_key", key.id, actor=None, verified=False,
|
||||
data={"days": days, "expires_at": expires.isoformat(), "name": key.name,
|
||||
"owner": owner.username})
|
||||
await session.commit()
|
||||
_noted.add((key.id, days))
|
||||
service.spawn(_key_one(key.id, days))
|
||||
out.append(key.id)
|
||||
return out
|
||||
|
||||
|
||||
async def _key_one(key_id: str, days: int) -> None:
|
||||
from app.notify import service
|
||||
|
||||
try:
|
||||
await service.notify_key_expiring(key_id, days)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("访问密钥 %s 快到期的提醒推送失败", key_id)
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""测试用:清掉进程里记的(认领过的日期、推过的密钥档)。"""
|
||||
_claimed.clear()
|
||||
_noted.clear()
|
||||
@@ -0,0 +1,425 @@
|
||||
"""邮件:通知渠道的第五种格式(docs/design/next-round/04-对外集成与触达.md B2,总方案 X12、集成-I4)。
|
||||
|
||||
为什么改写应用平台的 Q6(「待办通知只用 Webhook 的四种格式,不做邮件——SMTP 要另配服务器、还要处理退信」):
|
||||
- 待办派给了人却找不到人:群机器人群里人人看得见,算不上个人;用户表里本来就有 email(单点登录时自动填,管理员在
|
||||
「设置 → 身份」里也能改),邮件是真正能触达个人、成本最低的办法;
|
||||
- Q6 担心的两件事这里都照实处理,不假装做到:SMTP 是可选配置(设置里的 mail 一组,缺省关,不配就没有邮件这一格式);
|
||||
退信不追踪——投递记录成功时写的是「已交给邮件服务器」,是否进了收件箱以邮件服务器为准,界面上也照这样写;
|
||||
- IM 应用消息(企业微信、飞书、钉钉的应用消息)这次不做:要企业应用凭据、令牌刷新和用户 id 映射,和 IM 登录是同一套,
|
||||
放到一起做。
|
||||
|
||||
一种说法:邮件只是渠道的一种格式,订阅、投递记录(notify_deliveries)、测试、消息结构(core.Message:只有标题、状态、
|
||||
条数、链接,不放数字)都走同一套;不另建邮件表。
|
||||
|
||||
收件人:
|
||||
- 个人邮件渠道(scope=user)不填地址,只发到拥有者在名录里的账号邮箱(users.email):可以填任意地址的话,个人渠道就成了
|
||||
谁都能用的垃圾邮件中继,而且发到的不是认证过的身份。账号没有邮箱时记一条失败的投递并写明原因(NO_EMAIL);
|
||||
- 全局、应用的邮件渠道(搭建者以上能建)填固定的收件地址,最多 MAX_RECIPIENTS 个,和全局 Webhook 同一个信任级别。
|
||||
地址同 Webhook 一样加密落库(渠道表不改结构:个人渠道存一个占位)。
|
||||
|
||||
发送:标准库 smtplib,放在 asyncio.to_thread 里(不多一个依赖),单次超时 TIMEOUT_S 秒。4xx(临时错误)和连不上按
|
||||
1/2/4 秒重试,5xx(地址、认证、内容被拒)不重试——重试也一样。报错里只写主机名和服务器的回话,绝不出现口令(scrub)。
|
||||
测试把 _smtp_factory 换成假的(同 core._transport 的做法)。
|
||||
|
||||
邮件内容:纯文本一份、简单的 HTML 一份(全部转义);带 List-Unsubscribe(指向「设置 → 通知」,没有对外访问地址时不带:
|
||||
这个头只认完整的地址),Auto-Submitted: auto-generated。
|
||||
|
||||
设置的 mail 一组只有管理员能改(整个设置接口的改动都要管理员);口令用 core/crypto 加密存,读设置时只说配没配。
|
||||
内网的 SMTP 由管理员配置,不做 SSRF 检查(同全局 Webhook)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
from typing import Any
|
||||
|
||||
from app.notify.core import Message, Outcome, text_of
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 设置里的这一组
|
||||
GROUP = "mail"
|
||||
#: 加密方式:starttls(587,明文连上再升级)、ssl(465,一上来就是加密连接)、none(内网明文中继)
|
||||
SECURITY = ("starttls", "ssl", "none")
|
||||
SECURITY_LABEL = {"starttls": "STARTTLS", "ssl": "SSL/TLS", "none": "不加密"}
|
||||
DEFAULTS: dict[str, Any] = {"enabled": False, "host": None, "port": 587, "security": "starttls", "username": None,
|
||||
"password": None, "from_addr": None, "from_name": "AgentLab",
|
||||
"default_for_assignees": False}
|
||||
LABEL = {"enabled": "启用邮件", "host": "SMTP 服务器", "port": "端口", "security": "加密方式", "username": "用户名",
|
||||
"password": "口令", "from_addr": "发件地址", "from_name": "发件人名称",
|
||||
"default_for_assignees": "默认发邮件给待办处理人"}
|
||||
#: 单次连接、读写的超时(秒)
|
||||
TIMEOUT_S = 10.0
|
||||
#: 临时错误、连不上时再试 3 次,每次之前等这么久(秒)
|
||||
RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
|
||||
#: 全局、应用的邮件渠道最多几个收件地址
|
||||
MAX_RECIPIENTS = 20
|
||||
#: 个人邮件渠道 url 列存的占位(加密后落库):收件人一律取拥有者的账号邮箱
|
||||
PERSONAL_PLACEHOLDER = "account-email"
|
||||
#: 管理员开了「默认发邮件给待办处理人」时,自动建的个人邮件渠道默认订阅这几项(本人之后可以改、可以关)
|
||||
ASSIGNEE_EVENTS = ("task.opened", "action.unknown", "review.done")
|
||||
ASSIGNEE_CHANNEL_NAME = "待办邮件(自动建)"
|
||||
|
||||
NO_EMAIL = "你的账号没有邮箱地址,请联系管理员在「设置 → 身份」里填写"
|
||||
NOT_ENABLED = "邮件服务没有启用:请管理员在「设置 → 通知 → 邮件服务」里配置并启用"
|
||||
HANDED_OFF = "已交给邮件服务器"
|
||||
|
||||
#: 测试用:换成假的 SMTP 工厂,(host, port, security, timeout) → 有 starttls / login / send_message / quit / close 的对象
|
||||
_smtp_factory: Callable[[str, int, str, float], Any] | None = None
|
||||
|
||||
# 地址只认最常见的写法:本地部分不含空白和尖括号,域名至少有一个点。换行一律拒收(防邮件头注入)
|
||||
_ADDRESS = re.compile(r"^[^@\s<>\"(),;:\\]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$")
|
||||
_HOST = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$|^\[[0-9A-Fa-f:.]+\]$")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 地址、设置
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def address_ok(text: Any) -> bool:
|
||||
return isinstance(text, str) and len(text) <= 254 and bool(_ADDRESS.match(text))
|
||||
|
||||
|
||||
def parse_recipients(raw: Any) -> list[str]:
|
||||
"""全局、应用邮件渠道填的收件地址(逗号、分号、空白、换行分隔都行)→ 去重后的列表。不对抛 ValueError(中文说明)。"""
|
||||
if isinstance(raw, list):
|
||||
parts = [str(p) for p in raw]
|
||||
elif isinstance(raw, str):
|
||||
parts = re.split(r"[\s,;,;、]+", raw)
|
||||
else:
|
||||
parts = []
|
||||
out = list(dict.fromkeys(p.strip() for p in parts if p and p.strip()))
|
||||
if not out:
|
||||
raise ValueError("请填写至少一个收件地址,多个地址用逗号或换行分隔")
|
||||
if len(out) > MAX_RECIPIENTS:
|
||||
raise ValueError(f"收件地址最多 {MAX_RECIPIENTS} 个,当前填了 {len(out)} 个;人多的请改用邮件组的地址")
|
||||
bad = [p for p in out if not address_ok(p)]
|
||||
if bad:
|
||||
raise ValueError(f"收件地址「{bad[0][:80]}」不是有效的邮件地址")
|
||||
return out
|
||||
|
||||
|
||||
def _text(value: Any) -> str | None:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _port_ok(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535
|
||||
|
||||
|
||||
def settings_problem(value: Any, saved: Any = None) -> str | None:
|
||||
"""设置页写进来的 mail 一组有什么问题,没问题是 None。saved 是库里存着的那份(口令没给就沿用它)。
|
||||
|
||||
启用时要配全:服务器、发件地址;没启用时可以只填一部分先存着。发件人名称、主机名里不许有换行(邮件头注入)。"""
|
||||
if not isinstance(value, dict):
|
||||
return f"「邮件服务」设置格式有误,需要是一个对象,当前为「{value}」"
|
||||
for key, item in value.items():
|
||||
if key == "password_set":
|
||||
continue # GET 给的只读项,整组回写时带着它不算错
|
||||
if key not in DEFAULTS:
|
||||
return "「邮件服务」设置中不支持「" + key + "」,可设置的项:" + "、".join(f"「{LABEL[k]}」" for k in DEFAULTS)
|
||||
label = LABEL[key]
|
||||
if key in ("enabled", "default_for_assignees"):
|
||||
if not isinstance(item, bool):
|
||||
return f"「{label}」只能是开或关,当前为「{item}」"
|
||||
elif key == "port":
|
||||
if not _port_ok(item):
|
||||
return f"「{label}」需要是 1 到 65535 之间的整数,当前为「{item}」"
|
||||
elif key == "security":
|
||||
if item not in SECURITY:
|
||||
return f"「{label}」只能是 {'、'.join(SECURITY_LABEL.values())} 之一"
|
||||
elif item is not None and not isinstance(item, str):
|
||||
return f"「{label}」需要是文字,当前为「{item}」"
|
||||
elif isinstance(item, str) and any(c in item for c in "\r\n"):
|
||||
return f"「{label}」里不能有换行"
|
||||
elif key == "host" and _text(item) and not _HOST.match(item.strip()):
|
||||
return f"「{label}」需要是主机名或 IP 地址(例如 smtp.example.com),不带协议和端口,当前为「{item}」"
|
||||
elif key == "from_addr" and _text(item) and not address_ok(item.strip()):
|
||||
return f"「{label}」不是有效的邮件地址,当前为「{item}」"
|
||||
elif key in ("username", "from_name") and isinstance(item, str) and len(item) > 200:
|
||||
return f"「{label}」最长 200 个字"
|
||||
elif key == "password" and isinstance(item, str) and len(item) > 500:
|
||||
return f"「{label}」过长"
|
||||
merged = {**public_value(saved), **{k: v for k, v in value.items() if k in DEFAULTS and k != "password"}}
|
||||
if merged.get("enabled"):
|
||||
if not _text(merged.get("host")):
|
||||
return f"启用邮件之前请填写「{LABEL['host']}」"
|
||||
if not _text(merged.get("from_addr")):
|
||||
return f"启用邮件之前请填写「{LABEL['from_addr']}」"
|
||||
return None
|
||||
|
||||
|
||||
def stored_value(value: dict[str, Any], saved: Any = None) -> dict[str, Any]:
|
||||
"""校验过的 mail 一组 → 落库的样子:补齐默认、去掉首尾空白;口令没给(或给 null)沿用存着的,给空串表示去掉,
|
||||
给了新的就加密。"""
|
||||
from app.core.crypto import encrypt
|
||||
|
||||
old = saved if isinstance(saved, dict) else {}
|
||||
out = {**DEFAULTS, **{k: v for k, v in old.items() if k in DEFAULTS}}
|
||||
for key, item in value.items():
|
||||
if key not in DEFAULTS or key == "password":
|
||||
continue
|
||||
out[key] = item.strip() if isinstance(item, str) else item
|
||||
for key in ("host", "username", "from_addr", "from_name"):
|
||||
out[key] = _text(out.get(key))
|
||||
out["from_name"] = out["from_name"] or DEFAULTS["from_name"]
|
||||
given = value.get("password")
|
||||
if isinstance(given, str):
|
||||
out["password"] = encrypt(given) if given else None
|
||||
return out
|
||||
|
||||
|
||||
def public_value(stored: Any) -> dict[str, Any]:
|
||||
"""读设置时给的样子(GET /api/settings 的 mail 一组):口令不回,只说配没配(password_set)。存坏了的项按默认。"""
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
out: dict[str, Any] = {}
|
||||
for key, default in DEFAULTS.items():
|
||||
item = stored.get(key)
|
||||
if key in ("enabled", "default_for_assignees"):
|
||||
out[key] = item if isinstance(item, bool) else default
|
||||
elif key == "port":
|
||||
out[key] = item if _port_ok(item) else default
|
||||
elif key == "security":
|
||||
out[key] = item if item in SECURITY else default
|
||||
elif key == "password":
|
||||
out[key] = None
|
||||
else:
|
||||
out[key] = _text(item) or default
|
||||
out["password_set"] = bool(stored.get("password"))
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MailConfig:
|
||||
"""发信用的配置(口令已解密,只在发送时用)。"""
|
||||
|
||||
enabled: bool
|
||||
host: str | None
|
||||
port: int
|
||||
security: str
|
||||
username: str | None
|
||||
password: str | None
|
||||
from_addr: str | None
|
||||
from_name: str
|
||||
default_for_assignees: bool
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""配全了能发:服务器、发件地址都有。启不启用另看 enabled。"""
|
||||
return bool(self.host and self.from_addr)
|
||||
|
||||
|
||||
def config_of(stored: Any) -> MailConfig:
|
||||
from app.core.crypto import decrypt
|
||||
|
||||
shown = public_value(stored)
|
||||
secret = stored.get("password") if isinstance(stored, dict) else None
|
||||
return MailConfig(enabled=shown["enabled"], host=shown["host"], port=shown["port"], security=shown["security"],
|
||||
username=shown["username"], password=decrypt(secret) if isinstance(secret, str) else None,
|
||||
from_addr=shown["from_addr"], from_name=shown["from_name"],
|
||||
default_for_assignees=shown["default_for_assignees"])
|
||||
|
||||
|
||||
async def load_config(session: Any = None) -> MailConfig:
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Setting
|
||||
|
||||
if session is None:
|
||||
async with SessionLocal() as own:
|
||||
row = await own.get(Setting, GROUP)
|
||||
else:
|
||||
row = await session.get(Setting, GROUP)
|
||||
return config_of(row.value if row is not None else None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 邮件内容
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
_FOOTER = "这封邮件由 AgentLab 自动发出,请勿直接回复。不想再收到:在 AgentLab「设置 → 通知」里关掉或删除这个渠道"
|
||||
|
||||
|
||||
def build(cfg: MailConfig, to: list[str], msg: Message, *, unsubscribe: str | None) -> EmailMessage:
|
||||
"""一封邮件:纯文本一份、HTML 一份(全部转义)。unsubscribe 是「设置 → 通知」的完整地址,没有就不带退订头。"""
|
||||
em = EmailMessage()
|
||||
# 标题里的换行(应用名、节点名是人起的)合成空格:邮件头里有换行就是头注入,标准库也会直接拒收
|
||||
em["Subject"] = f"【AgentLab】{' '.join(msg.title.split())}"[:200]
|
||||
em["From"] = formataddr((cfg.from_name or DEFAULTS["from_name"], cfg.from_addr or ""))
|
||||
em["To"] = ", ".join(to)
|
||||
em["Date"] = formatdate(localtime=True)
|
||||
em["Message-ID"] = make_msgid(domain=(cfg.from_addr or "agentlab.invalid").rpartition("@")[2] or None)
|
||||
# 自动发出的通知:自动回复不该回给它(RFC 3834)
|
||||
em["Auto-Submitted"] = "auto-generated"
|
||||
if unsubscribe:
|
||||
em["List-Unsubscribe"] = f"<{unsubscribe}>"
|
||||
footer = _FOOTER + (f":{unsubscribe}" if unsubscribe else "")
|
||||
em.set_content(f"{text_of(msg)}\n\n——\n{footer}\n")
|
||||
em.add_alternative(_html(msg, unsubscribe), subtype="html")
|
||||
return em
|
||||
|
||||
|
||||
def _html(msg: Message, unsubscribe: str | None) -> str:
|
||||
esc = html.escape
|
||||
color = "#1a7f37" if msg.ok else "#9a6700"
|
||||
rows = "".join(
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#666;white-space:nowrap">{esc(k)}</td>'
|
||||
f'<td style="padding:2px 0;{"color:" + color + ";font-weight:600" if k == "状态" else ""}">{esc(v)}</td></tr>'
|
||||
for k, v in msg.lines)
|
||||
links = "".join(f'<p style="margin:8px 0"><a href="{esc(url, quote=True)}">{esc(label)}</a></p>'
|
||||
for label, url in msg.links if url.startswith(("http://", "https://")))
|
||||
tail = esc(_FOOTER) + (f':<a href="{esc(unsubscribe, quote=True)}">设置 → 通知</a>' if unsubscribe else "")
|
||||
return ('<!doctype html><html><body style="font-family:-apple-system,Segoe UI,PingFang SC,Microsoft YaHei,sans-serif;'
|
||||
'font-size:14px;line-height:1.6;color:#222">'
|
||||
f'<p style="margin:0 0 8px"><strong>【AgentLab】{esc(msg.title)}</strong></p>'
|
||||
f'<table style="border-collapse:collapse">{rows}</table>{links}'
|
||||
f'<p style="margin-top:16px;color:#888;font-size:12px">{tail}</p></body></html>')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _default_factory(host: str, port: int, security: str, timeout: float) -> Any:
|
||||
if security == "ssl":
|
||||
return smtplib.SMTP_SSL(host, port, timeout=timeout, context=ssl.create_default_context())
|
||||
return smtplib.SMTP(host, port, timeout=timeout)
|
||||
|
||||
|
||||
def scrub(text: str, cfg: MailConfig) -> str:
|
||||
"""报错里不出现口令(服务器回话里偶尔会把收到的内容原样带回来)。"""
|
||||
if cfg.password and len(cfg.password) >= 3:
|
||||
text = text.replace(cfg.password, "…")
|
||||
return text
|
||||
|
||||
|
||||
def _reply(error: Any) -> str:
|
||||
raw = getattr(error, "smtp_error", b"")
|
||||
text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw or "")
|
||||
return " ".join(text.split())[:200]
|
||||
|
||||
|
||||
def _send_once(cfg: MailConfig, to: list[str], em: EmailMessage) -> Outcome:
|
||||
"""连一次、发一次(同步,在线程里跑)。结果按 SMTP 的回码分:2xx 已交给邮件服务器;4xx、连不上值得再试;5xx 不再试。"""
|
||||
host = f"{cfg.host}:{cfg.port}"
|
||||
factory = _smtp_factory or _default_factory
|
||||
client = None
|
||||
try:
|
||||
client = factory(cfg.host or "", cfg.port, cfg.security, TIMEOUT_S)
|
||||
if cfg.security == "starttls":
|
||||
client.starttls(context=ssl.create_default_context())
|
||||
if cfg.username:
|
||||
client.login(cfg.username, cfg.password or "")
|
||||
refused = client.send_message(em, from_addr=cfg.from_addr, to_addrs=to) or {}
|
||||
except smtplib.SMTPNotSupportedError:
|
||||
return Outcome(False, None, 1, f"邮件服务器 {host} 不支持 STARTTLS:请把「加密方式」改成 SSL/TLS 或不加密")
|
||||
except smtplib.SMTPRecipientsRefused as e:
|
||||
codes = [code for code, _ in (e.recipients or {}).values()]
|
||||
temporary = any(400 <= int(c) < 500 for c in codes)
|
||||
return Outcome(False, None, 1, f"邮件服务器 {host} 拒收了全部 {len(codes) or len(to)} 个收件地址"
|
||||
+ ("(临时错误)" if temporary else ""), retryable=temporary)
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
code = int(e.smtp_code or 0)
|
||||
return Outcome(False, None, 1, scrub(f"邮件服务器 {host} 不接受这组用户名和口令({code})"
|
||||
+ (f":{_reply(e)}" if _reply(e) else ""), cfg)[:300],
|
||||
retryable=400 <= code < 500)
|
||||
except smtplib.SMTPResponseException as e:
|
||||
code = int(e.smtp_code or 0)
|
||||
detail = _reply(e)
|
||||
text = scrub(f"邮件服务器 {host} 回复 {code}" + (f":{detail}" if detail else ""), cfg)[:300]
|
||||
# SMTPConnectError 也走这里(连上了但对方回了错误码);回码不是 5xx 的都当临时错误
|
||||
return Outcome(False, None, 1, text, retryable=not 500 <= code < 600)
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
return Outcome(False, None, 1, f"邮件服务器 {host} 断开了连接", retryable=True)
|
||||
except ssl.SSLError:
|
||||
return Outcome(False, None, 1, f"和邮件服务器 {host} 建立加密连接失败:请核对「加密方式」和端口(SSL/TLS 常用 465,"
|
||||
"STARTTLS 常用 587)")
|
||||
except (socket.timeout, TimeoutError):
|
||||
return Outcome(False, None, 1, f"连接邮件服务器 {host} 超时({TIMEOUT_S:g} 秒)", retryable=True)
|
||||
except socket.gaierror:
|
||||
return Outcome(False, None, 1, f"找不到邮件服务器 {cfg.host}(域名无法解析)", retryable=True)
|
||||
except OSError:
|
||||
return Outcome(False, None, 1, f"无法连接邮件服务器 {host}", retryable=True)
|
||||
except Exception as e: # noqa: BLE001 - 拼信、编码出错也只记一条失败,不让推送任务抛出去
|
||||
logger.exception("发邮件时出错(%s)", host)
|
||||
return Outcome(False, None, 1, f"向邮件服务器 {host} 发信时出错({type(e).__name__})")
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
client.quit()
|
||||
except Exception: # noqa: BLE001 - 已经断开的连接 quit 会抛,关掉就行
|
||||
try:
|
||||
client.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if refused:
|
||||
# 部分地址被拒:其余的已经交给邮件服务器,算成功,照实写有几个没收
|
||||
return Outcome(True, None, 1, f"{HANDED_OFF},其中 {len(refused)} 个收件地址被拒收")
|
||||
return Outcome(True, None, 1)
|
||||
|
||||
|
||||
async def send(cfg: MailConfig, to: list[str], msg: Message, *, label: str, unsubscribe: str | None = None,
|
||||
retry: bool = True, delays: tuple[float, ...] | None = None) -> Outcome:
|
||||
"""发一封邮件(含重试)。label 是日志里的「谁的推送」。没配全的照实回失败,不连服务器。"""
|
||||
if not cfg.ready:
|
||||
return Outcome(False, None, 0, NOT_ENABLED)
|
||||
em = build(cfg, to, msg, unsubscribe=unsubscribe)
|
||||
waits = (RETRY_DELAYS if delays is None else delays) if retry else ()
|
||||
last = Outcome(False, None, 0, "没有发出邮件")
|
||||
for attempt in range(len(waits) + 1):
|
||||
if attempt:
|
||||
await asyncio.sleep(waits[attempt - 1])
|
||||
last = await asyncio.to_thread(_send_once, cfg, to, em)
|
||||
last.attempts = attempt + 1
|
||||
if last.ok:
|
||||
return last
|
||||
logger.warning("%s(邮件服务器 %s)第 %d 次发信失败:%s", label, cfg.host, attempt + 1, last.error)
|
||||
if not last.retryable:
|
||||
break
|
||||
return last
|
||||
|
||||
|
||||
async def recipients_of(session: Any, channel: Any) -> tuple[list[str], str | None]:
|
||||
"""这个邮件渠道发给谁:(地址, 发不了的原因)。个人渠道取拥有者的账号邮箱,其余解密填的那几个。"""
|
||||
from app.core.crypto import decrypt
|
||||
from app.db.models import User
|
||||
|
||||
if getattr(channel, "scope", None) == "user":
|
||||
user = await session.get(User, channel.owner_id) if channel.owner_id else None
|
||||
email = (user.email or "").strip() if user is not None else ""
|
||||
if not email or not address_ok(email):
|
||||
return [], NO_EMAIL
|
||||
return [email], None
|
||||
try:
|
||||
return parse_recipients(decrypt(channel.url) or ""), None
|
||||
except ValueError as e:
|
||||
return [], f"收件地址无法使用:{e}"
|
||||
|
||||
|
||||
async def deliver(channel: Any, message: dict[str, Any], msg: Message) -> Outcome:
|
||||
"""按渠道发一封邮件(notify/__init__.send 的邮件分支):配置没启用、收件人取不出来都记成失败、写明原因。"""
|
||||
from app.core import public_url
|
||||
from app.db.base import SessionLocal
|
||||
|
||||
async with SessionLocal() as session:
|
||||
cfg = await load_config(session)
|
||||
to, problem = await recipients_of(session, channel)
|
||||
base = await public_url.refresh(session)
|
||||
if not cfg.enabled:
|
||||
return Outcome(False, None, 0, NOT_ENABLED)
|
||||
if problem:
|
||||
return Outcome(False, None, 0, problem)
|
||||
return await send(cfg, to, msg, label=f"通知渠道「{channel.name}」",
|
||||
unsubscribe=f"{base}/settings/notify" if base else None,
|
||||
retry=bool(message.get("retry", True)))
|
||||
+187
-12
@@ -35,7 +35,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Task, TaskAssignment, User
|
||||
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User
|
||||
from app.notify.core import Message, Outcome, host_of
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,7 +66,7 @@ EVENTS: dict[str, Event] = {
|
||||
"action.unknown": Event("动作结果未知", _EVERY),
|
||||
# 个人应用提交审核之后,搭建者发布了或退回了(docs/design/normal-user-apps.md N7):发给提交人,不归哪个应用
|
||||
"review.done": Event("我提交审核的应用有结果", ("global", "user")),
|
||||
# ---- 本轮登记、还没有推送(总方案 X6):实现推送的那一块只改自己这一行的 scopes,消息照模块说明的规矩 ----
|
||||
# ---- 本轮登记的(总方案 X6):scopes 为空的还没有推送;实现推送的那一块只改自己这一行的 scopes,消息照模块说明的规矩 ----
|
||||
# 数据源有要处理的结构 / 取值变化(03 B3):只推 severity 为 action 的,按源汇总;源名、各级别条数、链接
|
||||
"source.changed": Event("数据源有变化要处理", ()),
|
||||
# 口径升版后召回重算,数真变了(02 第 7 项):答案的主人有 recall 待办;口径名、版本、链接,不放新旧值和差值
|
||||
@@ -76,10 +76,11 @@ EVENTS: dict[str, Event] = {
|
||||
# 叫法就照实带「定时」;没评完包括失败、到了上限、止损停下,所以不叫「失败」
|
||||
"eval.regressed": Event("定时评测有退步", ("global",)),
|
||||
"eval.failed": Event("定时评测没有评完", ("global",)),
|
||||
# 每天一条待办汇总(04 B3):只推个人渠道;条数、最早的时限、最多 5 条(节点在图上的名字)、去待办箱的链接
|
||||
"task.digest": Event("每天的待办汇总", ()),
|
||||
# 访问密钥到期前 7 天、1 天各一次(04 B3):个人密钥推本人,服务账号的推全局渠道;密钥名、到期日、链接
|
||||
"key.expiring": Event("访问密钥快到期", ()),
|
||||
# 每天一条待办汇总(04 B3,notify_digest):只推个人渠道;条数、最早的时限、最多 5 条(节点在图上的名字)、去待办箱的链接
|
||||
"task.digest": Event("每天的待办汇总", ("user",)),
|
||||
# 访问密钥到期前 7 天、1 天各一次(04 B3,notify_key_expiring):个人密钥推本人的个人渠道,服务账号的推全局渠道;
|
||||
# 密钥名、到期日、链接
|
||||
"key.expiring": Event("访问密钥快到期", ("global", "user")),
|
||||
}
|
||||
EVENT_LABEL = {name: event.label for name, event in EVENTS.items()}
|
||||
|
||||
@@ -135,14 +136,29 @@ async def personal_url_problem(url: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def channel_out(row: NotifyChannel) -> dict[str, Any]:
|
||||
"""渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。"""
|
||||
def channel_out(row: NotifyChannel, owner_email: str | None = None, *, recipients: bool = True) -> dict[str, Any]:
|
||||
"""渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。
|
||||
|
||||
邮件渠道(notify/mail.py)没有主机名:mail_to 是发给谁——个人渠道是拥有者的账号邮箱(owner_email,没有邮箱是空的),
|
||||
全局、应用的是填的那几个收件地址:改地址时要整份重填,所以给能改它的人照原样给。recipients 为假(调用方判定看的人改不了
|
||||
全局、应用的渠道,api/notify.list_channels)时不给地址、只给条数 mail_count:渠道列表对使用者照样列全局、应用渠道(应用
|
||||
的通知里要挑一个,只用名字和 id),Webhook 只给主机名,邮件的收件地址不能比它多露一层。"""
|
||||
from app.core.crypto import decrypt
|
||||
|
||||
url = decrypt(row.url) or ""
|
||||
return {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
|
||||
"url_host": host_of(url) if url else "", "signed": bool(row.secret), "events": list(row.events or []),
|
||||
"enabled": row.enabled}
|
||||
out = {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
|
||||
"url_host": host_of(url) if url and row.format != "email" else "", "signed": bool(row.secret),
|
||||
"events": list(row.events or []), "enabled": row.enabled}
|
||||
if row.format == "email":
|
||||
if row.scope == "user":
|
||||
out["mail_to"] = [owner_email] if owner_email else []
|
||||
else:
|
||||
to = [p.strip() for p in url.split(",") if p.strip()]
|
||||
if recipients:
|
||||
out["mail_to"] = to
|
||||
else:
|
||||
out["mail_count"] = len(to)
|
||||
return out
|
||||
|
||||
|
||||
def generic_body(message: dict[str, Any], msg: Message) -> dict[str, Any]:
|
||||
@@ -239,8 +255,61 @@ async def _where(session: Any, run: Run | None) -> str:
|
||||
return f"工作流「{run.workflow_name}」" if run.workflow_name else ""
|
||||
|
||||
|
||||
#: 管理员开了「默认发邮件给待办处理人」(设置 mail.default_for_assignees,04 细案 B2、集成-I5)之后,给哪些人自动建过个人
|
||||
#: 邮件渠道:设置表里一行 {用户 id: 建的时间}(通知自己记的账,键的开头同 notify/digest.STATE_PREFIX,设置接口不列)。只在
|
||||
#: 第一次派到待办时建一次——本人删掉了就是不要,不再替他建回来
|
||||
MAIL_AUTO_KEY = "notify_state:mail_auto"
|
||||
_mail_auto_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def ensure_assignee_mail(users: list[str]) -> int:
|
||||
"""派到待办的人还没有个人邮件渠道时自动给他建一个(默认订阅 mail.ASSIGNEE_EVENTS),本人之后可以改订阅、可以关。
|
||||
|
||||
只在开了登录(个人渠道挂在认证过的账号上)、邮件服务启用、管理员开了这个开关时做;服务账号、停用的人不建。缺省关:刚配好
|
||||
SMTP 的部署不该突然往外发邮件(照实说、不意外)。交回这次建了几个。同一时刻只做一份:两条待办同时派给同一个人不建两个。"""
|
||||
from app.auth import current_mode
|
||||
from app.core.crypto import encrypt
|
||||
from app.notify import mail
|
||||
|
||||
if not users or current_mode() == "off":
|
||||
return 0
|
||||
async with _mail_auto_lock, SessionLocal() as session:
|
||||
cfg = await mail.load_config(session)
|
||||
if not (cfg.enabled and cfg.default_for_assignees):
|
||||
return 0
|
||||
ids = list((await session.execute(select(User.id).where(
|
||||
User.username.in_(users), User.kind != "service", User.disabled.is_(False)))).scalars())
|
||||
record = await session.get(Setting, MAIL_AUTO_KEY)
|
||||
done = dict(record.value or {}) if record is not None else {}
|
||||
fresh = [uid for uid in ids if uid not in done]
|
||||
if not fresh:
|
||||
return 0
|
||||
have = set((await session.execute(select(NotifyChannel.owner_id).where(
|
||||
NotifyChannel.scope == "user", NotifyChannel.format == "email",
|
||||
NotifyChannel.owner_id.in_(fresh)))).scalars())
|
||||
made = 0
|
||||
for uid in fresh:
|
||||
if uid not in have:
|
||||
session.add(NotifyChannel(name=mail.ASSIGNEE_CHANNEL_NAME, scope="user", owner_id=uid, format="email",
|
||||
url=encrypt(mail.PERSONAL_PLACEHOLDER), events=list(mail.ASSIGNEE_EVENTS)))
|
||||
made += 1
|
||||
done[uid] = utcnow().isoformat()
|
||||
if record is None:
|
||||
session.add(Setting(key=MAIL_AUTO_KEY, value=done))
|
||||
else:
|
||||
record.value = done
|
||||
await session.commit()
|
||||
return made
|
||||
|
||||
|
||||
async def notify_task(task_id: str, event: str, *, users: list[str]) -> int:
|
||||
"""待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。"""
|
||||
"""待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。
|
||||
|
||||
管理员开了「默认发邮件给待办处理人」时,先给还没有个人邮件渠道的处理人建一个(ensure_assignee_mail),这一条就推得到。"""
|
||||
try:
|
||||
await ensure_assignee_mail(users)
|
||||
except Exception: # noqa: BLE001 - 自动建渠道出错不挡这条待办的通知
|
||||
logger.exception("给待办 %s 的处理人自动建邮件渠道失败", task_id)
|
||||
async with SessionLocal() as session:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
@@ -422,3 +491,109 @@ async def notify_eval(eval_run_id: str, event: str, *, passed: int, graded: int,
|
||||
"subject": {"eval_run_id": eval_run_id, "suite_id": run.suite_id, "schedule_id": run.schedule_id}}
|
||||
return await _push(channels, message, subject_kind="eval_run", subject_id=eval_run_id, event=event, run_id=None,
|
||||
task_id=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 每天的待办汇总、访问密钥快到期(04 细案 B3):什么时候推由 notify/digest.py 在调度循环里定,这里只拼消息、推送
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 汇总里最多列几条
|
||||
DIGEST_ITEMS = 5
|
||||
|
||||
|
||||
def _utc(moment: Any) -> Any:
|
||||
"""库里的时间一律按 UTC 认(待办的时限是不带时区的 UTC),比较前统一成带时区的。"""
|
||||
from datetime import timezone
|
||||
|
||||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _local(moment: Any) -> str:
|
||||
return _utc(moment).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
async def notify_digest(user_id: str) -> int:
|
||||
"""给一个人发今天的待办汇总(task.digest):只推他自己订阅了这件事的个人渠道。没有待办不发(不每天发一条「0 条」)。
|
||||
|
||||
内容(P4、X6):条数、最早的时限、最多 DIGEST_ITEMS 条(节点在图上的名字,加所在应用和时限)、去待办箱的链接;不放按运行
|
||||
数据渲染出来的待办标题。「派给我的」和待办箱同一个判断(process/tasks._mine)。返回推了几个渠道。"""
|
||||
from app.auth import current_mode
|
||||
from app.auth.sessions import principal_of
|
||||
from app.process.tasks import _mine
|
||||
|
||||
event = "task.digest"
|
||||
async with SessionLocal() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None or user.disabled or user.kind == "service":
|
||||
return 0
|
||||
channels = [c for c in await _channels(session, event, None, [user.username])
|
||||
if c.scope == "user" and c.owner_id == user.id]
|
||||
if not channels:
|
||||
return 0
|
||||
principal = await principal_of(session, user, current_mode())
|
||||
query = select(Task).where(Task.status == "pending")
|
||||
if (clause := _mine(principal)) is not None:
|
||||
query = query.where(clause)
|
||||
tasks = list((await session.execute(query)).scalars())
|
||||
if not tasks:
|
||||
return 0
|
||||
# 有时限的在前、时限早的在前;没有时限的按开出的先后
|
||||
far = _utc(utcnow().replace(year=9999))
|
||||
tasks.sort(key=lambda t: (_utc(t.due_at) if t.due_at else far, _utc(t.created_at) if t.created_at else far))
|
||||
runs = {r.id: r for r in (await session.execute(select(Run).where(
|
||||
Run.id.in_({t.run_id for t in tasks[:DIGEST_ITEMS]})))).scalars()}
|
||||
names = {a.id: a.name for a in (await session.execute(select(App).where(
|
||||
App.id.in_({t.app_id for t in tasks[:DIGEST_ITEMS] if t.app_id})))).scalars()}
|
||||
base = await _base_url(session)
|
||||
lines = [("状态", "待处理"), ("条数", f"{len(tasks)} 条")]
|
||||
dues = [t.due_at for t in tasks if t.due_at is not None]
|
||||
if dues:
|
||||
lines.append(("最早时限", _local(min(dues, key=_utc))))
|
||||
for task in tasks[:DIGEST_ITEMS]:
|
||||
run = runs.get(task.run_id)
|
||||
node = _label(run.graph if run else None, task.node_id) if task.node_id else task.title[:80]
|
||||
where = f"应用「{names[task.app_id]}」" if task.app_id in names else (
|
||||
f"工作流「{run.workflow_name}」" if run is not None and run.workflow_name else "")
|
||||
tail = [where] if where else []
|
||||
if task.due_at is not None:
|
||||
tail.append(f"时限 {_local(task.due_at)}")
|
||||
lines.append(("待办", f"「{node}」" + (f"({','.join(tail)})" if tail else "")))
|
||||
if len(tasks) > DIGEST_ITEMS:
|
||||
lines.append(("另有", f"{len(tasks) - DIGEST_ITEMS} 条,见待办箱"))
|
||||
message = {"event": event, "status": "pending", "title": f"你有 {len(tasks)} 条待办",
|
||||
"lines": lines, "links": [("去待办箱", f"{base}/apps?tab=inbox")] if base else [],
|
||||
"subject": {"user_id": user_id, "tasks": len(tasks)}}
|
||||
return await _push(channels, message, subject_kind="digest", subject_id=user_id, event=event, run_id=None,
|
||||
task_id=None)
|
||||
|
||||
|
||||
async def notify_key_expiring(key_id: str, days: int) -> int:
|
||||
"""访问密钥快到期了(key.expiring,到期前 7 天、1 天各一次,由 notify/digest 判断时候到没到、记审计)。
|
||||
|
||||
个人访问密钥推给拥有者自己订阅了这件事的个人渠道;服务账号的密钥推给订阅了它的全局渠道(服务账号不能登录,没有个人
|
||||
渠道)。消息只有密钥名、开头一截、到期时间和去轮换的链接,不放密钥本身。返回推了几个渠道。"""
|
||||
from app.db.models_identity import AccessKey
|
||||
|
||||
event = "key.expiring"
|
||||
async with SessionLocal() as session:
|
||||
key = await session.get(AccessKey, key_id)
|
||||
if key is None:
|
||||
return 0
|
||||
owner = await session.get(User, key.owner_user_id)
|
||||
if owner is None:
|
||||
return 0
|
||||
service = owner.kind == "service"
|
||||
found = await _channels(session, event, None, [] if service else [owner.username])
|
||||
channels = [c for c in found if (c.scope == "global" if service else c.scope == "user" and c.owner_id == owner.id)]
|
||||
if not channels:
|
||||
return 0
|
||||
base = await _base_url(session)
|
||||
left = "1 天内" if days <= 1 else f"{days} 天后"
|
||||
lines = [("状态", "快到期"), ("密钥", f"「{key.name[:80]}」({key.prefix}…)"),
|
||||
("拥有者", f"服务账号 {owner.username}" if service else (owner.display_name or owner.username)),
|
||||
("到期", _local(key.expires_at))]
|
||||
message = {"event": event, "status": "expiring", "title": f"访问密钥「{key.name[:80]}」将在{left}到期",
|
||||
"lines": lines, "links": [("去轮换", f"{base}/settings/keys")] if base else [],
|
||||
"subject": {"key_id": key_id, "days": days}}
|
||||
return await _push(channels, message, subject_kind="access_key", subject_id=key_id, event=event, run_id=None,
|
||||
task_id=None)
|
||||
|
||||
@@ -123,14 +123,17 @@ async def tick(now: datetime | None = None) -> None:
|
||||
async def process_due(now: datetime | None = None) -> None:
|
||||
"""流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查;
|
||||
上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉
|
||||
(auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写)。
|
||||
(auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写);每天到点发待办汇总、
|
||||
访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍)。
|
||||
各自出错只记日志:这一拍出错不该让调度循环停下,下一拍照常再查。"""
|
||||
from app.auth import keys
|
||||
from app.files import store as files_store
|
||||
from app.notify import digest
|
||||
from app.process import actions, tasks
|
||||
|
||||
for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick),
|
||||
("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick)):
|
||||
("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick),
|
||||
("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due)):
|
||||
try:
|
||||
done = await step(now)
|
||||
if isinstance(done, list) and done:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer:分享的 key 在地址的 # 后面,本来不随
|
||||
Referer 走,这里再收一道);
|
||||
- Content-Security-Policy: frame-ancestors。缺省只有 'self';AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户;分享页
|
||||
/s 只认 AGENTLAB_EMBED_ORIGINS(分享链接第 3 波才加,路由在那时挂,头的规则先在这里定好);
|
||||
/s 只认 AGENTLAB_EMBED_ORIGINS(分享链接,api/shares.py;页面是前端不进外壳的那一页,由 mount_web 照常托管);
|
||||
- 只有 frame-ancestors 是 'self' 时另加 X-Frame-Options: SAMEORIGIN,给不认 CSP 的老浏览器。XFO 写不了白名单,有白名单
|
||||
时只发 CSP:两个都发的话,只认 XFO 的老浏览器会把白名单里的门户也拦掉(认 CSP 的浏览器以 frame-ancestors 为准)。
|
||||
接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复。
|
||||
@@ -19,6 +19,15 @@
|
||||
index.html 里的内联脚本算 sha256(script_policy),只在页面这一个响应上发;观察一个版本没有误报,再改成强制执行。没配
|
||||
上报地址:违规只在浏览器的控制台里看得到。接口返回的 HTML(导出的报告、可验证报告)文件里自带 meta CSP,不发它,也不动。
|
||||
|
||||
嵌入的两种办法(04 细案 C3,只做配置和文档):
|
||||
- 同站门户嵌整站:门户和 AgentLab 在同一个可注册域名下(portal.corp.example 和 agentlab.corp.example)时,把门户的源写进
|
||||
AGENTLAB_FRAME_ANCESTORS。会话 cookie 是 SameSite=Lax,同站的 iframe 里会随请求带上:看的人照常登录、单点登录照常,权限
|
||||
和直接打开一样。不另做嵌入票据;
|
||||
- 跨站嵌:嵌整站明确不支持——跨站的 iframe 里带不上 Lax 的会话 cookie,登录不了;改成 SameSite=None 会削弱整站防跨站请求
|
||||
伪造的那一层,换来的嵌入在 Safari、Firefox 上默认仍然被拦。跨站要嵌,用分享链接(只读、必设到期、可撤销,/s 只认
|
||||
AGENTLAB_EMBED_ORIGINS)。
|
||||
deploy/agentlab.env 的「嵌入」一节写的是同一套。
|
||||
|
||||
HSTS 交给终结 https 的反向代理:这里不知道对外是不是 https、要不要连子域名一起管,发错了浏览器会按 max-age 记很久,
|
||||
撤不回来。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""每天的待办汇总、访问密钥快到期(notify/digest.py + notify/service,docs/design/next-round/04-对外集成与触达.md B3)。
|
||||
|
||||
守的是:
|
||||
- 待办汇总到设置的时间(notify.digest_at,按服务端时区)才发,只发给订阅了 task.digest 的个人渠道;内容是条数、最早时限、
|
||||
最多 5 条节点在图上的名字(不是按运行数据渲染出来的待办标题)、去待办箱的链接,不放数;
|
||||
- 同一天只发一次,重启(进程里记的清掉)也不重发:靠 settings 表里按日期认领的一行;第二天照常发;
|
||||
- 没有待办的人不发;
|
||||
- 访问密钥到期前 7 天、1 天各推一次,不多推;个人密钥推本人的个人渠道,服务账号的推全局渠道;每次推都记审计;
|
||||
- 轮换下来的旧密钥、撤销了的、建的时候就已经在这一档里的(自己设了短有效期),不推。
|
||||
|
||||
都用 Webhook 渠道和 httpx.MockTransport 接收;人名、应用名是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.crypto import encrypt
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import App, AuditEvent, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User
|
||||
from app.notify import core, digest, service
|
||||
from tests.fakes import auth_env
|
||||
|
||||
URL = "https://hooks.example.com/agentlab/digest-91c2"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def received(monkeypatch):
|
||||
got: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
got.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
|
||||
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
|
||||
|
||||
async def public(url: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(service, "personal_url_problem", public)
|
||||
return got
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def login_on(received):
|
||||
"""开着账号密码登录(个人渠道挂在认证过的账号上)。依赖 received:收尾时等推送推完,假的接收端还在。"""
|
||||
await auth_env.reset()
|
||||
digest.forget()
|
||||
async with SessionLocal() as session:
|
||||
# 别的测试可能留下了 notify 一组、渠道和投递记录:整组换成这里要的,渠道和记录先清掉
|
||||
await session.execute(delete(Setting).where(Setting.key == "notify"))
|
||||
await session.execute(delete(NotifyDelivery))
|
||||
await session.execute(delete(NotifyChannel))
|
||||
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com",
|
||||
"digest_at": "09:00"}))
|
||||
await session.commit()
|
||||
await auth_env.turn_on_local()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def _cleanup() -> None:
|
||||
await service.drain()
|
||||
async with SessionLocal() as session:
|
||||
mine = select(Run.id).where(Run.workflow_name == "汇总测试")
|
||||
tasks = select(Task.id).where(Task.run_id.in_(mine))
|
||||
await session.execute(delete(TaskAssignment).where(TaskAssignment.task_id.in_(tasks)))
|
||||
await session.execute(delete(Task).where(Task.run_id.in_(mine)))
|
||||
for model in (NotifyDelivery, NotifyChannel):
|
||||
await session.execute(delete(model))
|
||||
await session.execute(delete(Run).where(Run.workflow_name == "汇总测试"))
|
||||
await session.execute(delete(App).where(App.name == "报销审批"))
|
||||
await session.execute(delete(Setting).where(Setting.key.like(digest.STATE_PREFIX + "%")))
|
||||
await session.execute(delete(Setting).where(Setting.key == "notify"))
|
||||
await session.commit()
|
||||
from app.core import public_url
|
||||
|
||||
public_url.remember(None)
|
||||
digest.forget()
|
||||
await auth_env.reset()
|
||||
|
||||
|
||||
def local(hour: int, minute: int, *, day: int = 16) -> datetime:
|
||||
"""服务端所在时区的某个时刻(汇总按服务端时区算)→ UTC。"""
|
||||
return datetime(2026, 10, day, hour, minute).astimezone().astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def personal(user: User, events: list[str]) -> NotifyChannel:
|
||||
async with SessionLocal() as session:
|
||||
row = NotifyChannel(name=f"个人-{user.username}", scope="user", owner_id=user.id, format="generic",
|
||||
url=encrypt(URL), events=events)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def tasks_for(username: str, n: int, *, label: str = "主管审批") -> None:
|
||||
graph = {"nodes": [{"id": "review", "type": "human", "data": {"label": label}}], "edges": []}
|
||||
async with SessionLocal() as session:
|
||||
app = App(name="报销审批", published_version=1)
|
||||
session.add(app)
|
||||
run = Run(workflow_name="汇总测试", status="interrupted", graph=graph, input={"amount": 45678})
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
for i in range(n):
|
||||
task = Task(run_id=run.id, node_id="review", app_id=app.id, kind="approve",
|
||||
title=f"审批 45678 元的报销 #{i}", due_at=(utcnow() + timedelta(hours=i + 1)).replace(tzinfo=None))
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
session.add(TaskAssignment(task_id=task.id, principal_kind="user", principal=username))
|
||||
await session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 待办汇总
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_digest_is_sent_once_a_day_at_the_set_time(received):
|
||||
ana = await auth_env.make_user("ana", "user")
|
||||
bo = await auth_env.make_user("bo", "user")
|
||||
await personal(ana, ["task.digest"])
|
||||
await personal(bo, ["task.opened"]) # 没订阅汇总
|
||||
await tasks_for("ana", 7)
|
||||
await tasks_for("bo", 2)
|
||||
|
||||
assert await digest.digest_due(local(8, 59)) == [], "还没到点"
|
||||
assert await digest.digest_due(local(9, 0)) == [ana.id]
|
||||
await service.drain()
|
||||
[body] = received
|
||||
assert body["event"] == "task.digest" and body["title"] == "你有 7 条待办"
|
||||
text = body["text"]
|
||||
assert "条数:7 条" in text and "最早时限" in text
|
||||
assert text.count("「主管审批」") == 5, "最多列 5 条,用节点在图上的名字"
|
||||
assert "另有:2 条" in text and "报销审批" in text
|
||||
assert "45678" not in json.dumps(body, ensure_ascii=False), "不放按运行数据渲染出来的标题、不放数"
|
||||
assert body["links"] == [{"label": "去待办箱", "url": "https://agentlab.example.com/apps?tab=inbox"}]
|
||||
|
||||
assert await digest.digest_due(local(9, 15)) == [], "同一天只发一次"
|
||||
digest.forget() # 重启:进程里记的没了
|
||||
assert await digest.digest_due(local(10, 0)) == [], "重启了也不重发:认领在 settings 表里"
|
||||
await service.drain()
|
||||
assert len(received) == 1
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.event == "task.digest"))).scalars()
|
||||
assert [(r.subject_kind, r.subject_id, r.status) for r in rows] == [("digest", ana.id, "ok")]
|
||||
|
||||
assert await digest.digest_due(local(9, 1, day=17)) == [ana.id], "第二天照常"
|
||||
|
||||
|
||||
async def test_digest_follows_the_configured_time_and_skips_people_with_nothing_pending(received):
|
||||
cy = await auth_env.make_user("cy", "user")
|
||||
await personal(cy, ["task.digest"])
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Setting, "notify")
|
||||
row.value = {**row.value, "digest_at": "17:30"}
|
||||
await session.commit()
|
||||
assert await digest.digest_due(local(9, 0)) == []
|
||||
assert await digest.digest_due(local(17, 30)) == [cy.id]
|
||||
await service.drain()
|
||||
assert received == [], "没有待办不发「0 条」"
|
||||
|
||||
|
||||
async def test_nothing_is_sent_when_login_is_off(received):
|
||||
"""关登录时「派给我的」认不出是谁(人人等同管理员),访问密钥也不生效:汇总、到期提醒都不发,也不认领今天。"""
|
||||
ana = await auth_env.make_user("ana", "user")
|
||||
await personal(ana, ["task.digest"])
|
||||
await tasks_for("ana", 2)
|
||||
svc = await auth_env.make_service("svc-off")
|
||||
key, _ = await auth_env.make_key(svc, days=3)
|
||||
await _age(key, 30)
|
||||
from app.auth import remember_settings
|
||||
|
||||
remember_settings({})
|
||||
assert await digest.digest_due(local(9, 0)) == []
|
||||
assert await digest.keys_due(utcnow()) == []
|
||||
async with SessionLocal() as session:
|
||||
assert await session.get(Setting, digest.DIGEST_CLAIM + local(9, 0).astimezone().date().isoformat()) is None
|
||||
|
||||
|
||||
async def test_claim_rows_are_not_listed_as_settings(received):
|
||||
await digest.digest_due(local(9, 0))
|
||||
async with auth_env.client() as c:
|
||||
root = await auth_env.login("root")
|
||||
body = (await root.get("/api/settings")).json()
|
||||
assert not [k for k in body if k.startswith(digest.STATE_PREFIX)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 访问密钥快到期
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _expiring_audits(key_id: str) -> list[int]:
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(select(AuditEvent).where(AuditEvent.action == "access_key.expiring",
|
||||
AuditEvent.subject_id == key_id))).scalars()
|
||||
return sorted(r.data["days"] for r in rows)
|
||||
|
||||
|
||||
async def _age(key, days: float) -> None:
|
||||
from app.db.models_identity import AccessKey
|
||||
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(AccessKey, key.id)
|
||||
row.created_at = utcnow() - timedelta(days=days)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_personal_key_reminds_its_owner_at_7_and_1_days(received):
|
||||
await auth_env.personal_keys(True)
|
||||
dee = await auth_env.make_user("dee", "user")
|
||||
mine = await personal(dee, ["key.expiring"])
|
||||
async with SessionLocal() as session:
|
||||
session.add(NotifyChannel(name="全局", scope="global", format="generic", url=encrypt(URL),
|
||||
events=["key.expiring"]))
|
||||
await session.commit()
|
||||
key, secret = await auth_env.make_key(dee, days=30, name="周报脚本")
|
||||
await _age(key, 60)
|
||||
now = utcnow()
|
||||
assert await digest.keys_due(now) == [], "离到期还有 30 天"
|
||||
assert await digest.keys_due(now + timedelta(days=23, hours=1)) == [key.id]
|
||||
await service.drain()
|
||||
[body] = received
|
||||
assert body["event"] == "key.expiring" and "周报脚本" in body["title"] and "7 天后" in body["title"]
|
||||
assert secret not in json.dumps(body) and key.prefix in body["text"]
|
||||
assert body["links"] == [{"label": "去轮换", "url": "https://agentlab.example.com/settings/keys"}]
|
||||
async with SessionLocal() as session:
|
||||
to = (await session.execute(select(NotifyDelivery.channel_id))).scalars().all()
|
||||
assert to == [mine.id], "个人密钥只推本人,不推全局渠道"
|
||||
assert await digest.keys_due(now + timedelta(days=24)) == [], "这一档推过了"
|
||||
digest.forget()
|
||||
assert await digest.keys_due(now + timedelta(days=25)) == [], "重启后按审计认得出推过了"
|
||||
assert await digest.keys_due(now + timedelta(days=29, hours=1)) == [key.id], "到期前 1 天再推一次"
|
||||
assert await digest.keys_due(now + timedelta(days=29, hours=12)) == []
|
||||
assert await _expiring_audits(key.id) == [1, 7]
|
||||
await service.drain()
|
||||
assert len(received) == 2 and "1 天内" in received[1]["title"]
|
||||
|
||||
|
||||
async def test_service_account_keys_remind_global_channels(received):
|
||||
svc = await auth_env.make_service("svc-portal")
|
||||
async with SessionLocal() as session:
|
||||
glob = NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL), events=["key.expiring"])
|
||||
session.add(glob)
|
||||
await session.commit()
|
||||
key, _ = await auth_env.make_key(svc, days=5, name="门户拉数")
|
||||
await _age(key, 30)
|
||||
assert await digest.keys_due(utcnow()) == [key.id]
|
||||
await service.drain()
|
||||
[body] = received
|
||||
assert "服务账号 svc-portal" in body["text"]
|
||||
async with SessionLocal() as session:
|
||||
assert (await session.execute(select(NotifyDelivery.channel_id))).scalars().all() == [glob.id]
|
||||
|
||||
|
||||
async def test_keys_that_should_not_remind(received):
|
||||
from app.db.models_identity import AccessKey
|
||||
|
||||
svc = await auth_env.make_service("svc-bi")
|
||||
async with SessionLocal() as session:
|
||||
session.add(NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL),
|
||||
events=["key.expiring"]))
|
||||
await session.commit()
|
||||
short, _ = await auth_env.make_key(svc, days=3, name="临时") # 建的时候就只有 3 天:不推 7 天那一档
|
||||
old, _ = await auth_env.make_key(svc, days=0.5, name="轮换下来的")
|
||||
await _age(old, 90)
|
||||
new, _ = await auth_env.make_key(svc, days=90, name="轮换上去的")
|
||||
revoked, _ = await auth_env.make_key(svc, days=2, name="撤销了的")
|
||||
await _age(revoked, 30)
|
||||
async with SessionLocal() as session:
|
||||
(await session.get(AccessKey, new.id)).rotated_from = old.id
|
||||
(await session.get(AccessKey, revoked.id)).revoked_at = utcnow()
|
||||
await session.commit()
|
||||
assert await digest.keys_due(utcnow()) == []
|
||||
assert await digest.keys_due(utcnow() + timedelta(days=2, hours=1)) == [short.id], "1 天那一档照推"
|
||||
await service.drain()
|
||||
assert len(received) == 1
|
||||
@@ -32,8 +32,10 @@ URL = "https://hooks.example.com/agentlab/notify-events-5d1c"
|
||||
|
||||
#: 本轮登记的六个(总方案 X6)
|
||||
THIS_ROUND = ("source.changed", "ledger.recall", "eval.regressed", "eval.failed", "task.digest", "key.expiring")
|
||||
#: 其中已经推送的(第 3 波:定时评测的通知只推全局渠道,notify_eval),其余还没有
|
||||
PUSHED_THIS_ROUND = {"eval.regressed": {"global"}, "eval.failed": {"global"}}
|
||||
#: 其中已经实现了推送、打开了订阅的(推给哪几条路):定时评测的两个只推全局渠道(第 3 波 W3-c,notify_eval);每天的待办汇总
|
||||
#: 只推个人渠道;访问密钥快到期,个人密钥推本人、服务账号的推全局渠道(第 3 波 W3-d,notify/digest.py)。其余还没有
|
||||
PUSHED_THIS_ROUND = {"eval.regressed": {"global"}, "eval.failed": {"global"},
|
||||
"task.digest": {"user"}, "key.expiring": {"global", "user"}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -57,8 +59,9 @@ def test_this_rounds_events_are_registered_once_and_not_pushed_yet():
|
||||
assert name in service.EVENTS, f"{name} 没有登记"
|
||||
assert service.EVENTS[name].label, name
|
||||
if name in PUSHED_THIS_ROUND:
|
||||
continue
|
||||
assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到"
|
||||
assert set(service.EVENTS[name].scopes) == PUSHED_THIS_ROUND[name], name
|
||||
else:
|
||||
assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到"
|
||||
assert len({e.label for e in service.EVENTS.values()}) == len(service.EVENTS), "两个事件叫成了同一句"
|
||||
assert service.EVENT_LABEL == {k: v.label for k, v in service.EVENTS.items()}
|
||||
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""邮件:通知渠道的第五种格式(notify/mail.py,docs/design/next-round/04-对外集成与触达.md B2)。
|
||||
|
||||
全程用假的 SMTP 工厂(mail._smtp_factory),不连任何邮件服务器。守的是:
|
||||
- 个人邮件渠道只发到拥有者在名录里的账号邮箱;账号没有邮箱时记一条失败的投递、写明原因;个人渠道不收地址;
|
||||
- 全局、应用的邮件渠道填固定的收件地址(最多 20 个,格式不对的拒收);邮件服务没启用时不收新的邮件渠道;
|
||||
- 4xx(临时错误)和连不上按 1/2/4 秒重试,5xx 不重试;报错里绝不出现口令;
|
||||
- 邮件里只有标题、状态、链接,纯文本 + 转义过的 HTML,带 List-Unsubscribe;投递记录成功写「已交给邮件服务器」(不说已送达);
|
||||
- 设置的 mail 一组:口令加密存、读的时候只说配没配;启用时要配全;发件人名称不许换行;只有管理员能改;
|
||||
- 发测试邮件只发到管理员自己的账号邮箱:关登录、账号没有邮箱时 422 说清怎么办;
|
||||
- 管理员开了「默认发邮件给待办处理人」:第一次派到待办的人自动有一个个人邮件渠道(默认订阅三项),本人删了不再建回来;
|
||||
开关缺省关时不建。
|
||||
|
||||
人名、地址、主机都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import smtplib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.crypto import encrypt
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import NotifyChannel, NotifyDelivery, Setting, User
|
||||
from app.notify import mail, service
|
||||
from app.notify.core import Message
|
||||
|
||||
PASSWORD = "Sm7p-Pa55word-Zq"
|
||||
|
||||
|
||||
class FakeSMTP:
|
||||
"""假的 SMTP 连接:记下登录、发出的信;box.failures 里排着的异常按顺序在 send_message 上抛。"""
|
||||
|
||||
def __init__(self, box: SimpleNamespace) -> None:
|
||||
self.box = box
|
||||
|
||||
def starttls(self, context=None) -> None:
|
||||
self.box.tls += 1
|
||||
|
||||
def login(self, username: str, password: str) -> None:
|
||||
self.box.logins.append((username, password))
|
||||
if self.box.auth_error:
|
||||
# 有的服务器会把收到的内容回显在错误里:报错里也不能出现口令
|
||||
raise smtplib.SMTPAuthenticationError(535, f"5.7.8 bad credentials {password}".encode())
|
||||
|
||||
def send_message(self, em, from_addr=None, to_addrs=None):
|
||||
if self.box.failures:
|
||||
raise self.box.failures.pop(0)
|
||||
self.box.sent.append(SimpleNamespace(from_addr=from_addr, to=list(to_addrs or []), em=em))
|
||||
return {}
|
||||
|
||||
def quit(self) -> None:
|
||||
self.box.quits += 1
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp(monkeypatch):
|
||||
box = SimpleNamespace(sent=[], logins=[], failures=[], connects=[], refuse=0, tls=0, quits=0, auth_error=False)
|
||||
|
||||
def factory(host, port, security, timeout):
|
||||
box.connects.append((host, port, security, timeout))
|
||||
if box.refuse:
|
||||
box.refuse -= 1
|
||||
raise ConnectionRefusedError("refused")
|
||||
return FakeSMTP(box)
|
||||
|
||||
monkeypatch.setattr(mail, "_smtp_factory", factory)
|
||||
monkeypatch.setattr(mail, "RETRY_DELAYS", (0, 0, 0))
|
||||
return box
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean():
|
||||
"""前后各清一遍:同一个进程里别的测试留下的渠道、投递记录不算进来。"""
|
||||
await _clean()
|
||||
yield
|
||||
await service.drain()
|
||||
await _clean()
|
||||
|
||||
|
||||
async def _clean() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(NotifyDelivery))
|
||||
await session.execute(delete(NotifyChannel))
|
||||
await session.execute(delete(Setting).where(Setting.key.in_([mail.GROUP, "notify", service.MAIL_AUTO_KEY])))
|
||||
await session.execute(delete(User).where(User.username.like("mail-%")))
|
||||
await session.commit()
|
||||
from app.core import public_url
|
||||
|
||||
public_url.remember(None) # 发信时按库里的对外访问地址对齐了缓存:删了那一行,缓存也清掉
|
||||
|
||||
|
||||
async def configure(**over) -> None:
|
||||
value = mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587, "security": "starttls",
|
||||
"username": "agentlab", "password": PASSWORD, "from_addr": "agentlab@example.com",
|
||||
"from_name": "AgentLab", **over})
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Setting, mail.GROUP)
|
||||
if row is None:
|
||||
session.add(Setting(key=mail.GROUP, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
notify = await session.get(Setting, "notify")
|
||||
if notify is None:
|
||||
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com"}))
|
||||
else:
|
||||
# 别的测试留下的 notify 一组:对外访问地址换成这里要的(退订头按它拼)
|
||||
notify.value = {**(notify.value or {}), "public_base_url": "https://agentlab.example.com"}
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def person(name: str, email: str | None) -> User:
|
||||
async with SessionLocal() as session:
|
||||
user = User(username=f"mail-{name}", display_name=name, role="user", email=email)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def channel(scope: str, owner_id: str | None = None, to: str = mail.PERSONAL_PLACEHOLDER,
|
||||
events: list[str] | None = None) -> NotifyChannel:
|
||||
async with SessionLocal() as session:
|
||||
row = NotifyChannel(name=f"邮件-{scope}", scope=scope, owner_id=owner_id, format="email", url=encrypt(to),
|
||||
events=events or ["task.opened"])
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
MESSAGE = {"event": "task.opened", "status": "pending", "title": "新的待办:审批 <b>付款</b>",
|
||||
"lines": [("状态", "待处理"), ("所在", "应用「报销 & 付款」")],
|
||||
"links": [("去处理", "https://agentlab.example.com/apps/inbox/t1")]}
|
||||
|
||||
|
||||
async def _deliveries() -> list[NotifyDelivery]:
|
||||
async with SessionLocal() as session:
|
||||
return list((await session.execute(select(NotifyDelivery))).scalars())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发到谁
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_personal_mail_goes_to_the_account_email_only(smtp):
|
||||
await configure()
|
||||
ana = await person("ana", "ana@example.com")
|
||||
row = await channel("user", ana.id)
|
||||
sent = await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened",
|
||||
run_id=None, task_id="t1")
|
||||
assert sent == 1
|
||||
[mail_out] = smtp.sent
|
||||
assert mail_out.to == ["ana@example.com"] and mail_out.from_addr == "agentlab@example.com"
|
||||
assert smtp.logins == [("agentlab", PASSWORD)] and smtp.tls == 1
|
||||
em = mail_out.em
|
||||
assert em["Subject"] == "【AgentLab】新的待办:审批 <b>付款</b>"
|
||||
assert em["List-Unsubscribe"] == "<https://agentlab.example.com/settings/notify>"
|
||||
assert em["Auto-Submitted"] == "auto-generated"
|
||||
text = em.get_body(("plain",)).get_content()
|
||||
html = em.get_body(("html",)).get_content()
|
||||
assert "状态:待处理" in text and "https://agentlab.example.com/apps/inbox/t1" in text
|
||||
assert "<b>付款</b>" in html and "<b>付款</b>" not in html, "HTML 里的内容一律转义"
|
||||
assert "报销 & 付款" in html
|
||||
[delivery] = await _deliveries()
|
||||
assert (delivery.status, delivery.error, delivery.http_status) == ("ok", None, None)
|
||||
|
||||
|
||||
async def test_personal_mail_without_account_email_is_a_failed_delivery_with_the_reason(smtp):
|
||||
await configure()
|
||||
bo = await person("bo", None)
|
||||
row = await channel("user", bo.id)
|
||||
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
|
||||
task_id="t1")
|
||||
assert smtp.sent == [] and smtp.connects == [], "没有邮箱就不连服务器"
|
||||
[delivery] = await _deliveries()
|
||||
assert delivery.status == "failed" and delivery.error == mail.NO_EMAIL
|
||||
assert "设置 → 身份" in delivery.error
|
||||
|
||||
|
||||
async def test_global_mail_goes_to_the_fixed_addresses(smtp):
|
||||
await configure()
|
||||
row = await channel("global", to="ops@example.com, lead@example.com")
|
||||
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
|
||||
task_id="t1")
|
||||
assert smtp.sent[0].to == ["ops@example.com", "lead@example.com"]
|
||||
|
||||
|
||||
async def test_disabled_mail_service_records_why(smtp):
|
||||
await configure(enabled=False)
|
||||
row = await channel("global", to="ops@example.com")
|
||||
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
|
||||
task_id="t1")
|
||||
[delivery] = await _deliveries()
|
||||
assert delivery.status == "failed" and delivery.error == mail.NOT_ENABLED and smtp.connects == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 重试与报错
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cfg() -> mail.MailConfig:
|
||||
return mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587,
|
||||
"security": "starttls", "username": "agentlab", "password": PASSWORD,
|
||||
"from_addr": "agentlab@example.com"}))
|
||||
|
||||
|
||||
async def test_temporary_errors_and_refused_connections_are_retried(smtp):
|
||||
smtp.failures = [smtplib.SMTPDataError(451, b"4.3.0 try again later")]
|
||||
smtp.refuse = 1
|
||||
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
|
||||
assert out.ok is True and out.attempts == 3, "连不上一次、451 一次,第三次交给了邮件服务器"
|
||||
assert len(smtp.sent) == 1
|
||||
|
||||
|
||||
async def test_permanent_errors_are_not_retried(smtp):
|
||||
smtp.failures = [smtplib.SMTPDataError(550, b"5.7.1 message rejected")]
|
||||
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
|
||||
assert out.ok is False and out.attempts == 1 and out.retryable is False
|
||||
assert "550" in out.error and "smtp.example.com" in out.error
|
||||
|
||||
|
||||
async def test_retries_stop_after_three(smtp):
|
||||
smtp.refuse = 10
|
||||
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
|
||||
assert out.ok is False and out.attempts == 4 and len(smtp.connects) == 4
|
||||
assert out.error == "无法连接邮件服务器 smtp.example.com:587"
|
||||
|
||||
|
||||
async def test_errors_never_contain_the_password(smtp, caplog):
|
||||
smtp.auth_error = True
|
||||
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
|
||||
assert out.ok is False and out.attempts == 1, "认证失败(5xx)不重试"
|
||||
assert PASSWORD not in (out.error or "") and "535" in out.error
|
||||
assert PASSWORD not in caplog.text
|
||||
smtp.auth_error = False
|
||||
smtp.failures = [smtplib.SMTPSenderRefused(553, f"sender {PASSWORD} refused".encode(), "agentlab@example.com")]
|
||||
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
|
||||
assert PASSWORD not in (out.error or "")
|
||||
|
||||
|
||||
async def test_partially_refused_recipients_are_said_plainly(smtp, monkeypatch):
|
||||
monkeypatch.setattr(FakeSMTP, "send_message", lambda self, em, from_addr=None, to_addrs=None: {"x@example.com": (550, b"no")})
|
||||
out = await mail.send(_cfg(), ["ana@example.com", "x@example.com"], Message(title="t"), label="测试")
|
||||
assert out.ok is True and out.error == "已交给邮件服务器,其中 1 个收件地址被拒收"
|
||||
|
||||
|
||||
def test_line_breaks_in_titles_never_reach_the_headers():
|
||||
em = mail.build(_cfg(), ["ana@example.com"], Message(title="新的待办:审批\r\nBcc: x@example.com"), unsubscribe=None)
|
||||
assert em["Subject"] == "【AgentLab】新的待办:审批 Bcc: x@example.com" and em["Bcc"] is None
|
||||
|
||||
|
||||
def test_ssl_security_uses_no_starttls(smtp):
|
||||
cfg = mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 465,
|
||||
"security": "ssl", "from_addr": "agentlab@example.com"}))
|
||||
out = mail._send_once(cfg, ["ana@example.com"], mail.build(cfg, ["ana@example.com"], Message(title="t"),
|
||||
unsubscribe=None))
|
||||
assert out.ok and smtp.tls == 0 and smtp.logins == [], "没填用户名不登录"
|
||||
assert smtp.connects[0][:3] == ("smtp.example.com", 465, "ssl") and smtp.connects[0][3] == mail.TIMEOUT_S
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 渠道接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
from app.main import app
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def test_mail_channels_need_the_mail_service_and_valid_addresses(client, smtp):
|
||||
body = {"name": "运维邮件", "scope": "global", "format": "email", "url": "ops@example.com", "events": ["task.opened"]}
|
||||
refused = await client.post("/api/notify/channels", json=body)
|
||||
assert refused.status_code == 422 and refused.json()["detail"] == mail.NOT_ENABLED
|
||||
await configure()
|
||||
too_many = ", ".join(f"p{i}@example.com" for i in range(21))
|
||||
r = await client.post("/api/notify/channels", json={**body, "url": too_many})
|
||||
assert r.status_code == 422 and "最多 20 个" in r.json()["detail"]
|
||||
r = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com\nnot-an-address"})
|
||||
assert r.status_code == 422 and "not-an-address" in r.json()["detail"]
|
||||
made = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com;lead@example.com"})
|
||||
assert made.status_code == 201, made.text
|
||||
row = made.json()
|
||||
assert row["format"] == "email" and row["mail_to"] == ["ops@example.com", "lead@example.com"]
|
||||
assert row["url_host"] == "" and row["signed"] is False
|
||||
listed = (await client.get("/api/notify/channels")).json()
|
||||
assert listed["mail"] == {"enabled": True, "account_email": None}, "关登录时没有账号邮箱"
|
||||
tested = (await client.post(f"/api/notify/channels/{row['id']}/test")).json()
|
||||
assert tested["ok"] is True and tested["message"].startswith("已交给邮件服务器")
|
||||
assert "是否送达以邮件服务器为准" in tested["message"]
|
||||
deliveries = (await client.get(f"/api/notify/deliveries?channel_id={row['id']}")).json()["items"]
|
||||
assert [(d["event"], d["status"], d["format"]) for d in deliveries] == [("test", "ok", "email")]
|
||||
# 只改订阅、不传地址:照旧
|
||||
kept = await client.patch(f"/api/notify/channels/{row['id']}", json={"events": ["task.opened", "action.unknown"]})
|
||||
assert kept.status_code == 200 and kept.json()["mail_to"] == ["ops@example.com", "lead@example.com"]
|
||||
# 换成 Webhook 要填 Webhook 地址
|
||||
back = await client.patch(f"/api/notify/channels/{row['id']}", json={"format": "generic"})
|
||||
assert back.status_code == 422 and "Webhook 地址" in back.json()["detail"]
|
||||
|
||||
|
||||
async def test_personal_mail_channel_takes_no_address(smtp):
|
||||
from tests.fakes import auth_env
|
||||
|
||||
await configure()
|
||||
await auth_env.reset()
|
||||
try:
|
||||
await auth_env.turn_on_local()
|
||||
await auth_env.make_user("mail-uma", "user", email="uma@example.com")
|
||||
uma = await auth_env.login("mail-uma")
|
||||
r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
|
||||
"url": "someone@example.com", "events": ["task.opened"]})
|
||||
assert r.status_code == 422 and "账号邮箱" in r.json()["detail"], "个人渠道不能填任意地址(不当中继)"
|
||||
r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
|
||||
"events": ["task.opened", "task.digest"]})
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["mail_to"] == ["uma@example.com"]
|
||||
listed = (await uma.get("/api/notify/channels")).json()
|
||||
assert listed["mail"] == {"enabled": True, "account_email": "uma@example.com"}
|
||||
tested = (await uma.post(f"/api/notify/channels/{r.json()['id']}/test")).json()
|
||||
assert tested["ok"] is True and smtp.sent[-1].to == ["uma@example.com"]
|
||||
finally:
|
||||
await auth_env.reset()
|
||||
|
||||
|
||||
async def test_users_listing_channels_do_not_get_global_recipients(smtp):
|
||||
"""使用者列渠道照样看得到全局、应用渠道(应用的通知里要挑一个,只用名字和 id),但邮件渠道的收件地址只给能改它的人
|
||||
(搭建者以上):只给条数,不给地址。Webhook 只给主机名,邮件地址不能比它多露(复核 L2)。"""
|
||||
from tests.fakes import auth_env
|
||||
|
||||
await configure()
|
||||
await auth_env.reset()
|
||||
try:
|
||||
await auth_env.turn_on_local()
|
||||
await auth_env.make_user("mail-uma", "user", email="uma@example.com")
|
||||
await auth_env.make_user("mail-bo", "builder")
|
||||
everyone = "finance-lead@example.com, ops@example.com"
|
||||
made = await channel("global", to=everyone)
|
||||
await channel("app", to="team@example.com")
|
||||
uma = await auth_env.login("mail-uma")
|
||||
mine = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
|
||||
"events": ["task.opened"]})
|
||||
assert mine.status_code == 201, mine.text
|
||||
listed = (await uma.get("/api/notify/channels")).json()["items"]
|
||||
by = {row["scope"]: row for row in listed}
|
||||
assert set(by) == {"global", "app", "user"}, "全局、应用渠道照样列(应用的通知里要挑)"
|
||||
assert "finance-lead" not in json.dumps(listed) and "team@" not in json.dumps(listed), "收件地址不给使用者"
|
||||
assert "mail_to" not in by["global"] and by["global"]["mail_count"] == 2
|
||||
assert "mail_to" not in by["app"] and by["app"]["mail_count"] == 1
|
||||
assert by["user"]["mail_to"] == ["uma@example.com"], "自己的个人渠道照常写发到哪"
|
||||
bo = await auth_env.login("mail-bo")
|
||||
shown = {row["id"]: row for row in (await bo.get("/api/notify/channels")).json()["items"]}
|
||||
assert shown[made.id]["mail_to"] == ["finance-lead@example.com", "ops@example.com"], "能改的人看得到,改地址要整份重填"
|
||||
finally:
|
||||
await auth_env.reset()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 设置
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_mail_settings_keep_the_password_encrypted_and_hidden(client):
|
||||
put = await client.put("/api/settings", json={"values": {"mail": {
|
||||
"enabled": True, "host": "smtp.example.com", "port": 465, "security": "ssl", "username": "agentlab",
|
||||
"password": PASSWORD, "from_addr": "agentlab@example.com", "from_name": "流程助手"}}})
|
||||
assert put.status_code == 200, put.text
|
||||
shown = put.json()["mail"]
|
||||
assert shown["password"] is None and shown["password_set"] is True and PASSWORD not in json.dumps(put.json())
|
||||
async with SessionLocal() as session:
|
||||
stored = (await session.get(Setting, "mail")).value
|
||||
assert stored["password"] != PASSWORD and PASSWORD not in json.dumps(stored)
|
||||
assert mail.config_of(stored).password == PASSWORD
|
||||
# 整组回写(口令是 null、带着 password_set):口令照旧
|
||||
again = await client.put("/api/settings", json={"values": {"mail": {**shown, "from_name": "AgentLab"}}})
|
||||
assert again.status_code == 200 and again.json()["mail"]["password_set"] is True
|
||||
async with SessionLocal() as session:
|
||||
assert mail.config_of((await session.get(Setting, "mail")).value).password == PASSWORD
|
||||
# 空串表示去掉
|
||||
cleared = await client.put("/api/settings", json={"values": {"mail": {"password": ""}}})
|
||||
assert cleared.json()["mail"]["password_set"] is False
|
||||
assert cleared.json()["mail"]["host"] == "smtp.example.com", "只给一项的不冲掉别的"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, fragment", [
|
||||
({"enabled": True}, "SMTP 服务器"),
|
||||
({"enabled": True, "host": "smtp.example.com"}, "发件地址"),
|
||||
({"host": "smtp://smtp.example.com"}, "主机名"),
|
||||
({"port": 70000}, "端口"),
|
||||
({"security": "tls"}, "加密方式"),
|
||||
({"from_addr": "not-an-address"}, "发件地址"),
|
||||
({"from_name": "AgentLab\r\nBcc: x@example.com"}, "换行"),
|
||||
({"relay": True}, "不支持「relay」"),
|
||||
])
|
||||
async def test_mail_settings_are_validated(client, value, fragment):
|
||||
put = await client.put("/api/settings", json={"values": {"mail": value}})
|
||||
assert put.status_code == 422, put.text
|
||||
assert fragment in put.json()["detail"]
|
||||
|
||||
|
||||
async def test_mail_settings_are_admin_only_and_test_mail_goes_to_the_admin(smtp):
|
||||
from tests.fakes import auth_env
|
||||
|
||||
await configure()
|
||||
await auth_env.reset()
|
||||
try:
|
||||
async with auth_env.client() as off:
|
||||
r = await off.post("/api/settings/mail/test")
|
||||
assert r.status_code == 422 and r.json()["code"] == "mail_test_no_address", "关登录时没有账号邮箱"
|
||||
root = await auth_env.turn_on_local()
|
||||
r = await root.post("/api/settings/mail/test")
|
||||
assert r.status_code == 422 and "没有邮箱地址" in r.json()["detail"]
|
||||
async with SessionLocal() as session:
|
||||
admin = (await session.execute(select(User).where(User.username == "root"))).scalar_one()
|
||||
admin.email = "root@example.com"
|
||||
await session.commit()
|
||||
r = await root.post("/api/settings/mail/test")
|
||||
assert r.status_code == 200 and r.json()["ok"] is True, r.text
|
||||
assert "已交给邮件服务器" in r.json()["message"] and smtp.sent[-1].to == ["root@example.com"]
|
||||
await auth_env.make_user("mail-bea", "builder")
|
||||
bea = await auth_env.login("mail-bea")
|
||||
assert (await bea.get("/api/settings")).json()["mail"]["password"] is None, "搭建者读得到,看不到口令"
|
||||
assert (await bea.put("/api/settings", json={"values": {"mail": {"enabled": False}}})).status_code == 403
|
||||
assert (await bea.post("/api/settings/mail/test")).status_code == 403
|
||||
finally:
|
||||
await auth_env.reset()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 默认发邮件给待办处理人
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignees_get_a_personal_mail_channel_only_when_the_admin_turns_it_on(smtp):
|
||||
from tests.fakes import auth_env
|
||||
|
||||
await auth_env.reset()
|
||||
try:
|
||||
await auth_env.turn_on_local()
|
||||
cy = await person("cy", "cy@example.com")
|
||||
await configure()
|
||||
assert await service.ensure_assignee_mail([cy.username]) == 0, "缺省关:不建"
|
||||
await configure(default_for_assignees=True)
|
||||
assert await service.ensure_assignee_mail([cy.username]) == 1
|
||||
async with SessionLocal() as session:
|
||||
[row] = (await session.execute(select(NotifyChannel).where(NotifyChannel.owner_id == cy.id))).scalars()
|
||||
assert (row.scope, row.format, row.enabled) == ("user", "email", True)
|
||||
assert row.events == ["task.opened", "action.unknown", "review.done"]
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
assert await service.ensure_assignee_mail([cy.username]) == 0, "本人删了就是不要:不再建回来"
|
||||
finally:
|
||||
await auth_env.reset()
|
||||
|
||||
|
||||
async def test_assignee_mail_is_not_created_when_login_is_off(smtp):
|
||||
dee = await person("dee", "dee@example.com")
|
||||
await configure(default_for_assignees=True)
|
||||
assert await service.ensure_assignee_mail([dee.username]) == 0, "关登录没有认证过的账号,个人渠道无从挂起"
|
||||
@@ -109,13 +109,16 @@ async def test_env_locks_the_setting(monkeypatch):
|
||||
async with client() as c:
|
||||
body = (await c.get("/api/settings")).json()
|
||||
assert body["public_url"] == {"value": ENV_URL, "env_locked": True}
|
||||
assert body[NOTIFY_GROUP] == {"public_base_url": None}, "notify 一组照旧是库里存的那一份"
|
||||
assert body[NOTIFY_GROUP]["public_base_url"] is None, "notify 一组照旧是库里存的那一份"
|
||||
r = await put_address(c, SET_URL)
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.json()["code"] == "public_url_env_locked" and r.json()["field"] == "notify.public_base_url"
|
||||
assert "AGENTLAB_PUBLIC_URL" in r.json()["detail"]
|
||||
# 没改这一项(整组原样写回)不算改:不挡同一请求里别的设置
|
||||
assert (await put_address(c, None)).status_code == 200
|
||||
# 同一组里别的项(待办汇总的时间)照常能改:环境变量只锁对外访问地址这一项
|
||||
r = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "08:15"}}})
|
||||
assert r.status_code == 200 and r.json()[NOTIFY_GROUP]["digest_at"] == "08:15"
|
||||
# 只读的 public_url 一组写不进库
|
||||
r = await c.put("/api/settings", json={"values": {"public_url": {"value": SET_URL, "env_locked": False}}})
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -33,16 +33,17 @@ async def clean():
|
||||
async def test_daily_limit_is_a_registered_setting_with_default_200():
|
||||
async with auth_env.client() as c:
|
||||
body = (await c.get("/api/settings")).json()
|
||||
assert body[APPS_GROUP] == {"on_demand_daily_limit": 200, "user_personal_apps": False}
|
||||
assert body[APPS_GROUP] == {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}
|
||||
put = await c.put("/api/settings", json={"values": {APPS_GROUP: {"on_demand_daily_limit": 30}}})
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()[APPS_GROUP] == {"on_demand_daily_limit": 30, "user_personal_apps": False}
|
||||
assert put.json()[APPS_GROUP] == {"on_demand_daily_limit": 30, "user_personal_apps": False, "share_links": False}
|
||||
assert (await c.get("/api/settings")).json()[APPS_GROUP] == {"on_demand_daily_limit": 30,
|
||||
"user_personal_apps": False}
|
||||
"user_personal_apps": False, "share_links": False}
|
||||
async with SessionLocal() as session:
|
||||
assert await apps_settings(session) == {"on_demand_daily_limit": 30, "user_personal_apps": False}
|
||||
assert apps_values({"on_demand_daily_limit": "坏的", "user_personal_apps": "开"}) == {
|
||||
"on_demand_daily_limit": 200, "user_personal_apps": False}, "存坏了的按默认"
|
||||
assert await apps_settings(session) == {"on_demand_daily_limit": 30, "user_personal_apps": False,
|
||||
"share_links": False}
|
||||
assert apps_values({"on_demand_daily_limit": "坏的", "user_personal_apps": "开", "share_links": 1}) == {
|
||||
"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}, "存坏了的按默认"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, fragment", [
|
||||
@@ -81,7 +82,7 @@ async def test_auth_settings_are_not_read_or_written_here():
|
||||
await auth_env.make_user("bob", "builder")
|
||||
bob = await auth_env.login("bob")
|
||||
assert (await bob.get("/api/settings")).json()[APPS_GROUP] == {"on_demand_daily_limit": 200,
|
||||
"user_personal_apps": False}
|
||||
"user_personal_apps": False, "share_links": False}
|
||||
assert (await bob.put("/api/settings", json={"values": {APPS_GROUP: {"on_demand_daily_limit": 1}}})
|
||||
).status_code == 403, "设置页的应用一组只有管理员能改"
|
||||
assert (await bob.put("/api/settings", json={"values": {APPS_GROUP: {"user_personal_apps": True}}})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址。
|
||||
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址,和每天的待办汇总几点发
|
||||
(digest_at,缺省 09:00,04 细案 B3)。
|
||||
|
||||
- 默认没填(null),通知里就不附运行链接
|
||||
- 只收 http(s) 开头的完整地址;末尾的 / 去掉、首尾空白去掉,空串当没填;带 ? 或 # 的拒收
|
||||
@@ -31,9 +32,9 @@ def client() -> AsyncClient:
|
||||
async def test_default_is_no_link():
|
||||
async with client() as c:
|
||||
body = (await c.get("/api/settings")).json()
|
||||
assert body["notify"] == {"public_base_url": None}
|
||||
assert body["notify"] == {"public_base_url": None, "digest_at": "09:00"}
|
||||
async with SessionLocal() as session:
|
||||
assert await notify_settings(session) == {"public_base_url": None}
|
||||
assert await notify_settings(session) == {"public_base_url": None, "digest_at": "09:00"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("given, stored", [
|
||||
@@ -47,10 +48,10 @@ async def test_saved_and_normalized(given, stored):
|
||||
async with client() as c:
|
||||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": given}}})
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["notify"] == {"public_base_url": stored}
|
||||
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored}
|
||||
assert put.json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
|
||||
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
|
||||
async with SessionLocal() as session:
|
||||
assert await notify_settings(session) == {"public_base_url": stored}
|
||||
assert await notify_settings(session) == {"public_base_url": stored, "digest_at": "09:00"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, fragment", [
|
||||
@@ -73,5 +74,27 @@ async def test_invalid_values_are_refused_in_chinese(value, fragment):
|
||||
|
||||
|
||||
def test_broken_stored_value_reads_as_unset():
|
||||
assert notify_values({"public_base_url": "不是地址"}) == {"public_base_url": None}
|
||||
assert notify_values("坏掉的") == {"public_base_url": None}
|
||||
assert notify_values({"public_base_url": "不是地址", "digest_at": "25:00"}) == {"public_base_url": None,
|
||||
"digest_at": "09:00"}
|
||||
assert notify_values("坏掉的") == {"public_base_url": None, "digest_at": "09:00"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("given, ok", [("08:30", True), ("23:59", True), ("00:00", True), ("24:00", False),
|
||||
("9:00", False), ("09:60", False), (900, False), ("", False)])
|
||||
async def test_digest_time_is_hh_mm(given, ok):
|
||||
async with client() as c:
|
||||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": given}}})
|
||||
if ok:
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["notify"]["digest_at"] == given
|
||||
else:
|
||||
assert put.status_code == 422
|
||||
assert "待办汇总的发送时间" in put.json()["detail"]
|
||||
|
||||
|
||||
async def test_changing_digest_time_keeps_the_public_address():
|
||||
"""只给汇总时间的一组:对外访问地址不被冲成没填(设置页的通知分别保存这两项)。"""
|
||||
async with client() as c:
|
||||
await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": "https://agentlab.example.com"}}})
|
||||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "17:45"}}})
|
||||
assert put.json()["notify"] == {"public_base_url": "https://agentlab.example.com", "digest_at": "17:45"}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""分享链接(api/shares.py + apps/resolve.py 的 share 旁路,docs/design/next-round/04-对外集成与触达.md C2)。
|
||||
|
||||
守的是:
|
||||
- 管理员开关缺省关,关着建不了、已有的打不开;只有应用的 manage 能建;个人应用、没发布的、这一页没有定时出数的视图都建不了;
|
||||
- 到期必填,最长 90 天;
|
||||
- 读的接口不用登录,只认请求头 X-AgentLab-Share:地址里带 key 不认;库里只有 key 的哈希和开头一截;
|
||||
- 只解析定时出数(最近封存)的视图:按需视图写明原因、从不发起运行(声明了 auto 的也不);流程组件、报告换成写明原因的说明;
|
||||
- fixed 模式钉在建分享时的那几次运行上(趋势也停在那一期),latest 跟着最新的封存走;
|
||||
- 下载报告只认这条分享解析出来的运行,内部就是报告导出(遮罩值写「已遮罩」);
|
||||
- 撤销、到期回 410 并写明;建、撤销写审计,打开只计数;
|
||||
- 访问密钥一律调不了分享的接口;
|
||||
- /s 上的安全头:嵌入白名单用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer。
|
||||
|
||||
应用、工作流、人名都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.config import BACKEND_DIR
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import AuditEvent, Run, Setting
|
||||
from app.db.models_apps import ViewShare
|
||||
from tests.fakes import auth_env
|
||||
from tests.test_access_key_use import KPI, formal, published, wait, workflow
|
||||
from tests.test_access_key_use import world # noqa: F401 - 开登录、建管理员、收尾清掉应用
|
||||
|
||||
HEADER = "X-AgentLab-Share"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def switch(world): # noqa: F811
|
||||
await set_switch(True)
|
||||
yield
|
||||
async with SessionLocal() as s:
|
||||
await s.execute(delete(ViewShare))
|
||||
await s.execute(delete(Setting).where(Setting.key == "apps"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def set_switch(on: bool) -> None:
|
||||
async with SessionLocal() as s:
|
||||
row = await s.get(Setting, "apps")
|
||||
if row is None:
|
||||
s.add(Setting(key="apps", value={"share_links": on}))
|
||||
else:
|
||||
row.value = {**row.value, "share_links": on}
|
||||
await s.commit()
|
||||
|
||||
|
||||
def anonymous(**headers: str) -> AsyncClient:
|
||||
"""不带会话 cookie、不带 CSRF 头的客户端:拿到链接的人就是这样打开的。"""
|
||||
from app.main import app
|
||||
|
||||
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test", headers=headers)
|
||||
|
||||
|
||||
def board(wf: str, h: str, *, body: list | None = None, views: dict | None = None) -> dict:
|
||||
return {"format": "agentlab.app/1", "name": "周报看板",
|
||||
"pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": h, "use": "view"}],
|
||||
"views": views or {"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}},
|
||||
"pages": [{"id": "home", "title": "首页", "body": body or [
|
||||
{"id": "gmv", "type": "metric", "props": {"label": "销售额"},
|
||||
"bind": {"view": "latest", "metric": "card.gmv"}},
|
||||
{"id": "trend", "type": "series", "bind": {"view": "latest", "series": "card.gmv"}}]}]}
|
||||
|
||||
|
||||
def soon(days: float = 30) -> str:
|
||||
return (utcnow() + timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
async def share(app_id: str, **body) -> dict:
|
||||
admin = await auth_env.login("root")
|
||||
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon(), **body})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 建:开关、权限、到期
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_switch_and_manage_are_both_required(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
await auth_env.make_user("pia", "user")
|
||||
app_id = await published(world, board(wf, h), [("user", "pia", "observe")])
|
||||
admin = await auth_env.login("root")
|
||||
await set_switch(False)
|
||||
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()})
|
||||
assert r.status_code == 403 and r.json()["code"] == "share_links_off" and "允许建分享链接" in r.json()["detail"]
|
||||
listed = (await admin.get(f"/api/apps/{app_id}/shares")).json()
|
||||
assert listed["enabled"] is False and listed["items"] == [] and listed["max_days"] == 90
|
||||
await set_switch(True)
|
||||
pia = await auth_env.login("pia")
|
||||
r = await pia.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()})
|
||||
assert r.status_code == 403 and r.json()["code"] == "app_forbidden", "查看全部实例也不够,要管理权限"
|
||||
made = await share(app_id)
|
||||
assert made["path"] == f"/s#{made['key']}" and made["state"] == "active" and made["mode"] == "latest"
|
||||
async with SessionLocal() as s:
|
||||
row = await s.get(ViewShare, made["id"])
|
||||
assert row.key_hash != made["key"] and made["key"] not in json.dumps(
|
||||
{c.name: str(getattr(row, c.name)) for c in ViewShare.__table__.columns}), "库里没有 key 的明文"
|
||||
assert row.prefix == made["key"][:6] and made["prefix"] == row.prefix
|
||||
listed = (await admin.get(f"/api/apps/{app_id}/shares")).json()
|
||||
assert [i["id"] for i in listed["items"]] == [made["id"]] and "key" not in listed["items"][0]
|
||||
assert listed["items"][0]["page_title"] == "首页"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expires, fragment", [(None, "必须设到期时间"), (-1, "现在之后"), (91, "最长 90 天")])
|
||||
async def test_expiry_is_required_and_at_most_90_days(world, expires, fragment): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [])
|
||||
admin = await auth_env.login("root")
|
||||
body = {"page_id": "home", **({"expires_at": soon(expires)} if expires is not None else {})}
|
||||
r = await admin.post(f"/api/apps/{app_id}/shares", json=body)
|
||||
assert r.status_code == 422 and r.json()["code"] == "share_expires_invalid" and fragment in r.json()["detail"]
|
||||
ok = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon(90)})
|
||||
assert ok.status_code == 201
|
||||
|
||||
|
||||
async def test_pages_without_sealed_views_and_personal_apps_cannot_be_shared(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
on_demand = board(wf, h, views={"latest": {"source": {"kind": "on_demand", "pin": "main"}}},
|
||||
body=[{"id": "gmv", "type": "metric", "bind": {"view": "latest", "metric": "card.gmv"}}])
|
||||
app_id = await published(world, on_demand, [])
|
||||
admin = await auth_env.login("root")
|
||||
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()})
|
||||
assert r.status_code == 409 and r.json()["code"] == "share_no_sealed_view"
|
||||
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "nope", "expires_at": soon()})
|
||||
assert r.status_code == 404
|
||||
draft_only = (await admin.post("/api/apps", json={"name": "草稿", "spec": board(wf, h)})).json()["id"]
|
||||
world["apps"].append(draft_only)
|
||||
r = await admin.post(f"/api/apps/{draft_only}/shares", json={"page_id": "home", "expires_at": soon()})
|
||||
assert r.status_code == 409 and r.json()["code"] == "app_unpublished"
|
||||
from app.db.models import App
|
||||
|
||||
async with SessionLocal() as s:
|
||||
(await s.get(App, draft_only)).scope = "personal"
|
||||
await s.commit()
|
||||
r = await admin.post(f"/api/apps/{draft_only}/shares", json={"page_id": "home", "expires_at": soon()})
|
||||
assert r.json()["code"] in ("personal.not_shareable", "app_not_found"), r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 打开
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_page_is_public_but_only_trusts_the_header(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [])
|
||||
run = await formal(wf)
|
||||
made = await share(app_id)
|
||||
async with anonymous() as c:
|
||||
missing = await c.get("/api/shares/page", params={"key": made["key"]})
|
||||
assert missing.status_code == 400 and missing.json()["code"] == "share_key_missing", "地址里带的 key 不认"
|
||||
wrong = await c.get("/api/shares/page", headers={HEADER: made["key"][:-2] + "zz"})
|
||||
assert wrong.status_code == 404 and wrong.json()["code"] == "share_not_found"
|
||||
r = await c.get("/api/shares/page", headers={HEADER: made["key"]})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["format"] == "agentlab.share/1"
|
||||
assert body["share"]["created_by"] == "root" and body["share"]["mode"] == "latest"
|
||||
view = body["view"]
|
||||
assert view["format"] == "agentlab.view/1" and view["sources"]["latest"]["run_id"] == run.id
|
||||
gmv = view["tree"][0]["data"]
|
||||
assert gmv["state"] == "ok" and "4,321.5" in gmv["text"], "看到的就是使用者看到的那份"
|
||||
assert view["sources"]["latest"]["stamp"]["sealed"] is True, "印章照常算"
|
||||
assert body["downloads"] == [{"view": "latest", "run_id": run.id, "reports": [],
|
||||
"as_of_text": view["sources"]["latest"]["stamp"]["as_of_text"]}]
|
||||
|
||||
|
||||
async def test_only_sealed_views_and_flow_components_become_placeholders(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
spec = board(wf, h, views={
|
||||
"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}},
|
||||
"ask": {"source": {"kind": "on_demand", "pin": "main", "auto": True}}}, body=[
|
||||
{"id": "gmv", "type": "metric", "bind": {"view": "latest", "metric": "card.gmv"}},
|
||||
{"id": "asked", "type": "metric", "bind": {"view": "ask", "metric": "card.gmv"}},
|
||||
{"id": "todo", "type": "task_list"},
|
||||
{"id": "mine", "type": "instance_list"}])
|
||||
app_id = await published(world, spec, [])
|
||||
await formal(wf)
|
||||
made = await share(app_id)
|
||||
async with SessionLocal() as s:
|
||||
before = await s.scalar(select(func.count(Run.id)))
|
||||
async with anonymous(**{HEADER: made["key"]}) as c:
|
||||
view = (await c.get("/api/shares/page")).json()["view"]
|
||||
async with SessionLocal() as s:
|
||||
assert await s.scalar(select(func.count(Run.id))) == before, "打开分享页从不发起运行(声明了 auto 的按需视图也不)"
|
||||
by = {n["id"]: n for n in view["tree"]}
|
||||
assert by["gmv"]["data"]["state"] == "ok"
|
||||
assert by["asked"]["data"]["state"] == "empty" and "只显示定时出数的视图" in by["asked"]["data"]["message"]
|
||||
for cid, kind in (("todo", "task_list"), ("mine", "instance_list")):
|
||||
assert by[cid]["type"] == "notice" and by[cid]["share_placeholder"] == kind and by[cid]["props"]["text"]
|
||||
assert "data" not in by[cid], "不取实例、待办"
|
||||
|
||||
|
||||
async def test_fixed_mode_stays_on_the_runs_it_was_created_with(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [])
|
||||
first = await formal(wf, "W1")
|
||||
fixed = await share(app_id, mode="fixed")
|
||||
latest = await share(app_id)
|
||||
second = await formal(wf, "W2")
|
||||
async with anonymous(**{HEADER: fixed["key"]}) as c:
|
||||
pinned = (await c.get("/api/shares/page")).json()
|
||||
other = await c.get("/api/shares/report.html", params={"run": second.id})
|
||||
assert other.status_code == 404 and other.json()["code"] == "share_run_not_shared"
|
||||
async with anonymous(**{HEADER: latest["key"]}) as c:
|
||||
moving = (await c.get("/api/shares/page")).json()
|
||||
assert pinned["view"]["sources"]["latest"]["run_id"] == first.id
|
||||
points = pinned["view"]["tree"][1]["data"]["series"][-1]["points"]
|
||||
assert [p["run_id"] for p in points] == [first.id], "趋势也停在建分享的那一期"
|
||||
assert moving["view"]["sources"]["latest"]["run_id"] == second.id
|
||||
|
||||
|
||||
async def test_runs_issued_from_questions_are_never_the_latest(world): # noqa: F811
|
||||
"""问数据的答案按口径正式出具的运行(ledger_issue)参数是从某人的提问里绑出来的:应用页、趋势、分享链接(latest 和建
|
||||
fixed 时取的那次)、视图数据接口的「最近一次封存」都不取它,下载报告也不认它(复核 H1)。"""
|
||||
from app.engine.launch import launch_run
|
||||
|
||||
svc = await auth_env.make_service()
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [("user", "svc-portal", "observe")])
|
||||
regular = await formal(wf, "W1")
|
||||
async with SessionLocal() as s:
|
||||
launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={"week": "W9"},
|
||||
actor="编辑", origin_kind="ledger_issue", origin={"source_run": "提问那次"},
|
||||
expect_hash=h)
|
||||
issued = await wait(launched.id)
|
||||
assert issued.status == "succeeded" and issued.manifest_hash, "出具的运行成功并封存了,是更新的那一次"
|
||||
admin = await auth_env.login("root")
|
||||
page = (await admin.get(f"/api/apps/{app_id}/pages/home")).json()
|
||||
assert page["sources"]["latest"]["run_id"] == regular.id, "看板不换成某人提问的那组参数"
|
||||
points = page["tree"][1]["data"]["series"][-1]["points"]
|
||||
assert [p["run_id"] for p in points] == [regular.id], "趋势里也没有它"
|
||||
_, secret = await auth_env.make_key(svc, scopes=("apps.read",))
|
||||
data = (await auth_env.keyed(secret).get(f"/api/apps/{app_id}/views/latest/data")).json()
|
||||
assert data["state"] == "ok" and data["run_id"] == regular.id, "视图数据接口同一个取法"
|
||||
fixed = await share(app_id, mode="fixed")
|
||||
latest = await share(app_id)
|
||||
for made in (fixed, latest):
|
||||
async with anonymous(**{HEADER: made["key"]}) as c:
|
||||
body = (await c.get("/api/shares/page")).json()
|
||||
assert body["view"]["sources"]["latest"]["run_id"] == regular.id, made["mode"]
|
||||
refused = await c.get("/api/shares/report.html", params={"run": issued.id})
|
||||
assert refused.status_code == 404 and refused.json()["code"] == "share_run_not_shared", made["mode"]
|
||||
|
||||
|
||||
def test_every_run_origin_is_classified_for_latest_sealed():
|
||||
"""每种运行来源都要说清算不算「最近一次封存」:以后加了来源、没在 apps/resolve 归类的,这里先失败。"""
|
||||
from app.apps.resolve import NOT_SEALED_ORIGINS, SEALED_ORIGINS
|
||||
from app.engine.origin import ORIGIN_KINDS
|
||||
|
||||
assert not set(SEALED_ORIGINS) & set(NOT_SEALED_ORIGINS)
|
||||
assert set(SEALED_ORIGINS) | set(NOT_SEALED_ORIGINS) == set(ORIGIN_KINDS)
|
||||
assert "ledger_issue" in NOT_SEALED_ORIGINS and "app_backfill" in NOT_SEALED_ORIGINS
|
||||
|
||||
|
||||
async def test_report_download_only_for_the_shares_runs_and_masked(world, monkeypatch, tmp_path): # noqa: F811
|
||||
import sqlite3
|
||||
|
||||
from app.data.engine import engines
|
||||
from app.engine.launch import launch_run
|
||||
from tests import test_report_masked_metrics as vipmod
|
||||
|
||||
path = tmp_path / "vip.db"
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);")
|
||||
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", vipmod.CUSTOMERS)
|
||||
db.commit()
|
||||
db.close()
|
||||
source_id = await vipmod.set_source(path, {"mask_columns": ["phone"]})
|
||||
await engines.invalidate(source_id)
|
||||
world["sources"].append("vip")
|
||||
vipmod.script(monkeypatch, vipmod.CLEAN)
|
||||
wf, h = await workflow(vipmod.graph(on_violation="flag"))
|
||||
spec = board(wf, h, body=[{"id": "n", "type": "metric", "bind": {"view": "latest", "metric": "card.n_cust"}},
|
||||
{"id": "doc", "type": "report", "bind": {"view": "latest", "report": "write"}}])
|
||||
app_id = await published(world, spec, [])
|
||||
async with SessionLocal() as s:
|
||||
launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={},
|
||||
actor="编辑", origin_kind="launcher")
|
||||
run = await wait(launched.id)
|
||||
assert run.status == "succeeded", run.error
|
||||
made = await share(app_id)
|
||||
kpi_wf, _ = await workflow(KPI)
|
||||
stranger = await formal(kpi_wf)
|
||||
async with anonymous(**{HEADER: made["key"]}) as c:
|
||||
body = (await c.get("/api/shares/page")).json()
|
||||
by = {n["id"]: n for n in body["view"]["tree"]}
|
||||
assert by["doc"]["type"] == "notice" and "下载报告" in by["doc"]["props"]["text"], "报告正文不在线打开"
|
||||
[download] = body["downloads"]
|
||||
assert download["run_id"] == run.id and download["reports"] == ["write"]
|
||||
report = await c.get("/api/shares/report.html", params={"run": run.id})
|
||||
assert report.status_code == 200, report.text
|
||||
assert "attachment" in report.headers["content-disposition"]
|
||||
assert vipmod.leaks(report.text) == [], "报告里没有遮罩列的原值"
|
||||
refused = await c.get("/api/shares/report.html", params={"run": stranger.id})
|
||||
assert refused.status_code == 404 and refused.json()["code"] == "share_run_not_shared"
|
||||
no_header = await anonymous().get("/api/shares/report.html", params={"run": run.id})
|
||||
assert no_header.status_code == 400
|
||||
async with SessionLocal() as s:
|
||||
exported = (await s.execute(select(AuditEvent).where(AuditEvent.action == "report.exported",
|
||||
AuditEvent.subject_id == run.id))).scalars().all()
|
||||
assert [(e.actor, e.verified) for e in exported] == [(None, False)], "导出记审计,导出人不冒充谁"
|
||||
|
||||
|
||||
async def test_revoked_and_expired_links_say_so_with_410(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [])
|
||||
await formal(wf)
|
||||
gone = await share(app_id)
|
||||
old = await share(app_id)
|
||||
admin = await auth_env.login("root")
|
||||
r = await admin.delete(f"/api/apps/{app_id}/shares/{gone['id']}")
|
||||
assert r.status_code == 200 and r.json()["state"] == "revoked" and r.json()["revoked_by"] == "root"
|
||||
async with SessionLocal() as s:
|
||||
(await s.get(ViewShare, old["id"])).expires_at = utcnow() - timedelta(minutes=5)
|
||||
await s.commit()
|
||||
async with anonymous() as c:
|
||||
r = await c.get("/api/shares/page", headers={HEADER: gone["key"]})
|
||||
assert r.status_code == 410 and r.json()["code"] == "share_revoked" and "链接已撤销" in r.json()["detail"]
|
||||
r = await c.get("/api/shares/page", headers={HEADER: old["key"]})
|
||||
assert r.status_code == 410 and r.json()["code"] == "share_expired" and "链接已到期" in r.json()["detail"]
|
||||
live = await share(app_id)
|
||||
await set_switch(False)
|
||||
async with anonymous(**{HEADER: live["key"]}) as c:
|
||||
r = await c.get("/api/shares/page")
|
||||
assert r.status_code == 403 and r.json()["code"] == "share_links_off", "管理员关掉开关,已有的一并打不开"
|
||||
assert (await admin.delete(f"/api/apps/{app_id}/shares/{live['id']}")).status_code == 200, "关着也能撤销"
|
||||
|
||||
|
||||
async def test_create_and_revoke_are_audited_and_opening_only_counts(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [])
|
||||
await formal(wf)
|
||||
made = await share(app_id, embed_allowed=True)
|
||||
async with anonymous(**{HEADER: made["key"]}) as c:
|
||||
for _ in range(3):
|
||||
assert (await c.get("/api/shares/page")).status_code == 200
|
||||
admin = await auth_env.login("root")
|
||||
await admin.delete(f"/api/apps/{app_id}/shares/{made['id']}")
|
||||
await admin.delete(f"/api/apps/{app_id}/shares/{made['id']}")
|
||||
async with SessionLocal() as s:
|
||||
row = await s.get(ViewShare, made["id"])
|
||||
actions = (await s.execute(select(AuditEvent.action, AuditEvent.actor).where(
|
||||
AuditEvent.subject_id == made["id"]).order_by(AuditEvent.at))).all()
|
||||
assert row.open_count == 3 and row.last_opened_at is not None and row.embed_allowed is True
|
||||
assert actions == [("app.share_create", "root"), ("app.share_revoke", "root")], "打开不记审计;撤销两次只记一次"
|
||||
|
||||
|
||||
async def test_access_keys_cannot_reach_share_routes(world): # noqa: F811
|
||||
wf, h = await workflow(KPI)
|
||||
app_id = await published(world, board(wf, h), [])
|
||||
made = await share(app_id)
|
||||
svc = await auth_env.make_service()
|
||||
_, secret = await auth_env.make_key(svc)
|
||||
c = auth_env.keyed(secret, **{HEADER: made["key"]})
|
||||
for method, path in (("GET", "/api/shares/page"), ("GET", f"/api/apps/{app_id}/shares"),
|
||||
("POST", f"/api/apps/{app_id}/shares")):
|
||||
r = await c.request(method, path, json={"page_id": "home", "expires_at": soon()} if method == "POST" else None)
|
||||
assert r.status_code == 403 and r.json()["code"] == "key_route_forbidden", (method, path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# /s 的安全头
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PROBE = r"""
|
||||
import asyncio, json, httpx
|
||||
from app.main import app
|
||||
|
||||
async def main():
|
||||
t = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=t, base_url="http://t") as c:
|
||||
out = {}
|
||||
for p in ("/s", "/apps/x", "/api/shares/page"):
|
||||
r = await c.get(p)
|
||||
out[p] = {"status": r.status_code, **{k: r.headers.get(k) for k in (
|
||||
"content-security-policy", "x-frame-options", "referrer-policy")}}
|
||||
print(json.dumps(out))
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
|
||||
|
||||
def test_share_page_uses_the_embed_whitelist_and_no_referrer(tmp_path: Path) -> None:
|
||||
dist = tmp_path / "dist"
|
||||
(dist / "assets").mkdir(parents=True)
|
||||
(dist / "index.html").write_text("<!doctype html><html><body><div id=root></div></body></html>")
|
||||
env = {**os.environ, "AGENTLAB_DATA_DIR": str(tmp_path / "data"), "AGENTLAB_WEB_DIST": str(dist),
|
||||
"AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", "AGENTLAB_EMBED_ORIGINS": "https://bi.example.org",
|
||||
"AGENTLAB_FRAME_ANCESTORS": ""}
|
||||
proc = subprocess.run([sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, capture_output=True, text=True,
|
||||
timeout=120)
|
||||
assert proc.returncode == 0, proc.stderr[-2000:]
|
||||
out = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
assert out["/s"]["status"] == 200, "分享页由页面托管给出(不进外壳由前端定)"
|
||||
assert out["/s"]["content-security-policy"] == "frame-ancestors 'self' https://bi.example.org"
|
||||
assert out["/s"]["x-frame-options"] is None and out["/s"]["referrer-policy"] == "no-referrer"
|
||||
assert out["/apps/x"]["content-security-policy"] == "frame-ancestors 'self'", "别的页面不跟着放开嵌入"
|
||||
assert out["/apps/x"]["x-frame-options"] == "SAMEORIGIN"
|
||||
assert out["/api/shares/page"]["status"] == 400, "关登录时也只认请求头"
|
||||
@@ -4,7 +4,7 @@
|
||||
- 页面、/api 的响应(连同登录检查自己回的 401)都带 nosniff、Referrer-Policy 和 CSP frame-ancestors;
|
||||
- frame-ancestors 缺省 'self',只有 'self' 时另加 X-Frame-Options: SAMEORIGIN;配了 AGENTLAB_FRAME_ANCESTORS 白名单时
|
||||
以 CSP 为准、不发 XFO(XFO 写不了白名单,两个都发的话老浏览器按 XFO 把白名单里的门户也拦了);
|
||||
- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(路由第 3 波才加,头的规则先守住);
|
||||
- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(真实页面托管下的头另见 test_shares.py);
|
||||
- 严格的 script-src 只发 Content-Security-Policy-Report-Only,只在页面(index.html)上发,哈希就是构建产物里内联
|
||||
脚本的 sha256;接口返回的 HTML(导出的报告自带 meta CSP)不带它;
|
||||
- 接口自己设过的同名头不覆盖、不重复;WebSocket 不经过它;
|
||||
|
||||
+8
-2
@@ -38,10 +38,16 @@ BIND_ADDR=0.0.0.0
|
||||
# X-Frame-Options: SAMEORIGIN),别的站点嵌进去会被浏览器拦下,防点击劫持。HSTS 不在这里加,交给终结 https 的反向代理。
|
||||
# 同站的门户(和 AgentLab 在同一个可注册域名下,比如 portal.example.internal 和 agentlab.example.internal)要嵌整站时,
|
||||
# 把门户的地址写在下面,逗号分隔,只写 协议://主机[:端口],主机可以写 *.example.internal。填了之后只发
|
||||
# frame-ancestors、不再发 X-Frame-Options。登录用的 cookie 只随同站的请求带上,跨站的门户嵌整站登录不了。
|
||||
# frame-ancestors、不再发 X-Frame-Options。同站时登录用的会话 cookie(SameSite=Lax)会随 iframe 里的请求带上,看的人在
|
||||
# 门户里照常登录、单点登录照常;嵌进去的就是整站,权限和直接打开一样。
|
||||
# 跨站的门户(不在同一个可注册域名下)嵌整站不支持:浏览器不把会话 cookie 带进跨站的 iframe,登录不了;为此把 cookie
|
||||
# 改成 SameSite=None 会削弱整站防跨站请求伪造的那一层,Safari、Firefox 默认还是会拦。跨站要嵌,请用下面的分享链接。
|
||||
# 写错了(带路径、没写协议、只写 *)服务启动时报错。
|
||||
# AGENTLAB_FRAME_ANCESTORS=https://portal.example.internal
|
||||
# 分享页(/s)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项。分享链接还没有上线,现在填了不起作用
|
||||
# 分享页(/s,分享链接打开的只读页面)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项;不填时只有 AgentLab
|
||||
# 自己的页面能嵌。分享链接要管理员在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」,再由管理应用的人在
|
||||
# 应用编辑的「岗位与授权」里建(必设到期、最长 90 天,可撤销);建的时候没勾「允许嵌入」的那一条,在别的页面的 iframe 里
|
||||
# 不显示内容。分享页另带 Referrer-Policy: no-referrer
|
||||
# AGENTLAB_EMBED_ORIGINS=https://bi.example.internal
|
||||
|
||||
# ---- 数据 ----
|
||||
|
||||
@@ -32,6 +32,7 @@ import { NewAppStart } from './pages/blueprints/NewAppStart'
|
||||
import { BlueprintPage } from './pages/blueprints/BlueprintPage'
|
||||
import { personalAppsOn } from './pages/blueprints/shared'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { SharePage } from './pages/apps/SharePage'
|
||||
import { Logo } from './components/Logo'
|
||||
import {
|
||||
CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify,
|
||||
@@ -56,7 +57,17 @@ import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TAS
|
||||
/** 启动时认人(/auth/me)最多等多久再取目录 */
|
||||
const AUTH_WAIT_MS = 2500
|
||||
|
||||
/**
|
||||
* 分享页(/s#<分享 key>,04 细案 C2)不在外壳里,比登录页还早一步分开:拿到链接的人没有登录这回事,外壳的认人、取目录、
|
||||
* 心跳、读主题一样都不做(开了登录时它们会 401,再被请求层送去登录页)。整页加载时定一次:分享页上没有通往外壳的链接
|
||||
*/
|
||||
const SHARE_PAGE = typeof window !== 'undefined' && window.location.pathname === '/s'
|
||||
|
||||
export default function App() {
|
||||
return SHARE_PAGE ? <SharePage /> : <Shell />
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const loaded = useCatalog((s) => s.loaded)
|
||||
const backend = useCatalog((s) => s.backend)
|
||||
const everOk = useCatalog((s) => s.lastOkAt != null)
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
import type {
|
||||
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
|
||||
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel,
|
||||
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, ViewShareCreated, ViewShareList,
|
||||
ViewShareRow,
|
||||
} from '../typesApp'
|
||||
import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
|
||||
import { del, get, patch, post, put, qs, type RequestOptions } from './http'
|
||||
import { del, get, patch, post, put, qs, request, type RequestOptions } from './http'
|
||||
|
||||
const at = (id: string) => `/apps/${encodeURIComponent(id)}`
|
||||
const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}`
|
||||
@@ -74,6 +75,15 @@ export const apps = {
|
||||
deleteHook: (id: string, name: string) => del(hookAt(id, name)),
|
||||
/** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */
|
||||
hookReceipts: (id: string, name: string) => get<HookReceipts>(`${hookAt(id, name)}/receipts`),
|
||||
/**
|
||||
* 分享链接(manage,服务端 api/shares.py):列、建、撤销。建要管理员开了「允许建分享链接」,到期必填(最长 max_days 天);
|
||||
* key 只在建的这一次交回
|
||||
*/
|
||||
shares: (id: string) => get<ViewShareList>(`${at(id)}/shares`),
|
||||
createShare: (id: string, body: { page_id: string; mode: 'latest' | 'fixed'; expires_at: string; embed_allowed: boolean }) =>
|
||||
post<ViewShareCreated>(`${at(id)}/shares`, body),
|
||||
revokeShare: (id: string, shareId: string) =>
|
||||
request<ViewShareRow>(`${at(id)}/shares/${encodeURIComponent(shareId)}`, { method: 'DELETE' }),
|
||||
/**
|
||||
* 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次;
|
||||
* 老服务端只回对话本身时由调用方再按 id 取一次
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types'
|
||||
import type {
|
||||
CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow,
|
||||
NotifyEventRow,
|
||||
NotifyEventRow, NotifyMailState,
|
||||
} from '../typesApp'
|
||||
import { del, get, patch, post, put, qs, type RequestOptions } from './http'
|
||||
|
||||
@@ -122,8 +122,10 @@ export const endpoints = {
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
channels: () => get<{ items: NotifyChannelRow[] }>('/notify/channels'),
|
||||
createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url: string;
|
||||
/** mail:邮件服务启没启用、本人的账号邮箱(老服务端不给) */
|
||||
channels: () => get<{ items: NotifyChannelRow[]; mail?: NotifyMailState }>('/notify/channels'),
|
||||
/** 邮件渠道:个人的不带 url(只发到账号邮箱),全局、应用的 url 是收件地址(逗号或换行分隔,最多 20 个) */
|
||||
createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url?: string;
|
||||
secret?: string | null; events: string[] }) => post<NotifyChannelRow>('/notify/channels', body),
|
||||
updateChannel: (id: string, body: Record<string, any>) => patch<NotifyChannelRow>(`/notify/channels/${encodeURIComponent(id)}`, body),
|
||||
removeChannel: (id: string) => del(`/notify/channels/${encodeURIComponent(id)}`),
|
||||
|
||||
@@ -689,6 +689,8 @@ export const api = {
|
||||
/** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */
|
||||
judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number
|
||||
daily_max_usd?: number | null }>('/settings/judge/spend'),
|
||||
/** 按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(管理员;关登录、没有邮箱时 422) */
|
||||
mailTest: () => post<{ ok: boolean; message: string }>('/settings/mail/test'),
|
||||
},
|
||||
|
||||
// ---- 工具 ----
|
||||
|
||||
@@ -104,7 +104,8 @@ export function actorHeader(): Record<string, string> {
|
||||
function toLogin(path?: string) {
|
||||
if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return
|
||||
const { pathname, search } = window.location
|
||||
if (pathname === '/login') return
|
||||
// 分享页(/s)上没有登录这回事:不送去登录页
|
||||
if (pathname === '/login' || pathname === '/s') return
|
||||
const back = `${pathname}${search}`
|
||||
window.location.assign(back && back !== '/' ? `/login?next=${encodeURIComponent(back)}` : '/login')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 分享页(/s)的两个接口:GET /api/shares/page、GET /api/shares/report.html(服务端 api/shares.py,04 细案 C2)。
|
||||
*
|
||||
* 分享 key 在链接的 # 后面(/s#<key>),打开页面时不发给服务器;这里读出来放进请求头 X-AgentLab-Share,服务端只认请求头。
|
||||
* 不经 http.request:那条路会带署名头、把 401 当成「会话没了」整页跳去登录页——分享页上没有登录这回事,也不该在外壳的连接
|
||||
* 状态里报来报去。报错照样翻成 ApiError({detail, code}),页面按 code 写「链接已撤销 / 已到期」。
|
||||
*
|
||||
* 只从 ./http、./bundle 导入(同 reportExport.ts,避免和 client.ts 循环导入)。
|
||||
*/
|
||||
import type { SharePayload } from '../typesApp'
|
||||
import { ApiError, BASE, NETWORK_MESSAGE, codeOf, describeDetail } from './http'
|
||||
import { dispositionName } from './bundle'
|
||||
|
||||
export const SHARE_HEADER = 'X-AgentLab-Share'
|
||||
|
||||
async function call(path: string, key: string): Promise<Response> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(BASE + path, { headers: { [SHARE_HEADER]: key }, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
||||
} catch (e: any) {
|
||||
throw new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
|
||||
}
|
||||
if (res.ok) return res
|
||||
const text = await res.text().catch(() => '')
|
||||
let body: any = null
|
||||
try { body = JSON.parse(text) } catch { /* 不是 JSON(代理回的页面) */ }
|
||||
const detail = body?.detail
|
||||
throw new ApiError(res.status, detail ? describeDetail(detail, path) : `请求失败(HTTP ${res.status})`,
|
||||
{ detail, code: codeOf(body), body, raw: `${res.status} ${text.slice(0, 500)}` })
|
||||
}
|
||||
|
||||
export const shares = {
|
||||
/** 分享页的视图模型、页脚、能下载报告的运行 */
|
||||
page: async (key: string): Promise<SharePayload> => (await call('/shares/page', key)).json(),
|
||||
/** 下载报告(附件):只认这条分享解析出来的运行;文件名取服务端给的,没给是 null */
|
||||
report: async (key: string, runId: string, reportNode?: string): Promise<{ blob: Blob; name: string | null }> => {
|
||||
const q = new URLSearchParams({ run: runId })
|
||||
if (reportNode) q.set('report', reportNode)
|
||||
const res = await call(`/shares/report.html?${q.toString()}`, key)
|
||||
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
|
||||
},
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
* 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个
|
||||
* 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。
|
||||
* 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。
|
||||
*
|
||||
* 分享页(pages/apps/SharePage,04 细案 C2)也用它:传 preview(不发起、不刷新)和 onEvidence——点数字不打开在线出处(那要
|
||||
* 按运行读证据的身份,分享页上没有),交给分享页说明「出处在下载的报告里」。
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -35,6 +38,8 @@ export interface ViewRendererProps {
|
||||
onReload?: () => void
|
||||
/** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */
|
||||
editing?: ViewEditMarks
|
||||
/** 点数字看出处时不开证据面板、交给调用方(分享页:出处在下载的报告里) */
|
||||
onEvidence?: (evidence: ViewEvidence, label: string, text?: string) => void
|
||||
}
|
||||
|
||||
/** 编辑状态的标记:草稿里隐藏了的组件 id */
|
||||
@@ -47,11 +52,12 @@ const EditMarksCtx = createContext<ViewEditMarks | null>(null)
|
||||
/** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */
|
||||
const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id)
|
||||
|
||||
export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) {
|
||||
export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing, onEvidence }: ViewRendererProps) {
|
||||
const [evidence, setEvidence] = useState<EvidenceTarget | null>(null)
|
||||
const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => {
|
||||
setEvidence({ evidence: ev, label, text })
|
||||
}, [])
|
||||
if (onEvidence) onEvidence(ev, label, text)
|
||||
else setEvidence({ evidence: ev, label, text })
|
||||
}, [onEvidence])
|
||||
// 视图模型换了(刷新、重新取)就是新的一代:画坏了的组件跟着重试一次
|
||||
const gen = useMemo(() => String(++generation), [model])
|
||||
const render = useCallback((nodes: ViewNode[] | undefined): ReactNode =>
|
||||
@@ -81,7 +87,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate,
|
||||
)}
|
||||
<EditMarksCtx.Provider value={editing ?? null}>{render(model.tree)}</EditMarksCtx.Provider>
|
||||
</div>
|
||||
<EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />
|
||||
{!onEvidence && <EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />}
|
||||
</ViewCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5672,7 +5672,8 @@ export const NOTIFY_TEXT = {
|
||||
urlHint: '地址加密保存,之后只显示主机名',
|
||||
secret: '签名密钥(可选)',
|
||||
format: '格式',
|
||||
formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人' } as Record<string, string>,
|
||||
// email:邮件(第五种格式,服务端 notify/mail.py):要先在「邮件服务」一节配好并启用
|
||||
formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人', email: '邮件' } as Record<string, string>,
|
||||
scope: '范围',
|
||||
scopes: { global: '全局', app: '应用', user: '个人' } as Record<string, string>,
|
||||
events: '订阅的事件',
|
||||
@@ -6146,6 +6147,9 @@ export const APP_SETTING_TEXT = {
|
||||
onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数',
|
||||
personalApps: '使用者可以建个人应用',
|
||||
personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制',
|
||||
// 分享链接(服务端 apps.share_links,04 细案 C2):默认关
|
||||
shareLinks: '允许建分享链接',
|
||||
shareLinksHint: '开启后,管理应用的人可以在应用编辑的「岗位与授权」里给一页建分享链接:拿到链接的人不用登录就能看(只读、只显示定时出数的视图、数据和使用者看到的一样遮过),必须设到期、最长 90 天、随时可以撤销。关掉时已有的分享链接一并打不开',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -802,3 +802,122 @@ export const HOOK_RECEIPT_TEXT = {
|
||||
? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数`
|
||||
: '近 1 小时没有验签失败的请求'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件(通知渠道的第五种格式,服务端 notify/mail.py,04 细案 B2):设置 → 通知里的「邮件服务」「每天的待办汇总」两节、
|
||||
* 渠道里的邮件格式。照实说:成功只写「已交给邮件服务器」,退信不追踪,不说已送达
|
||||
*/
|
||||
export const MAIL_TEXT = {
|
||||
section: '邮件服务',
|
||||
hint: '通知渠道选「邮件」时用这台邮件服务器发信。口令加密保存,之后只显示「已设置」',
|
||||
readOnly: '只有管理员能修改邮件服务',
|
||||
enabled: '启用邮件',
|
||||
enabledHint: '关着时通知渠道里没有「邮件」这一格式;已有的邮件渠道发不出去,投递记录里写明原因',
|
||||
host: 'SMTP 服务器',
|
||||
hostHint: '主机名或 IP 地址,例如 smtp.example.com,不带协议和端口',
|
||||
port: '端口',
|
||||
security: '加密方式',
|
||||
securityLabel: { starttls: 'STARTTLS(常用端口 587)', ssl: 'SSL/TLS(常用端口 465)', none: '不加密(只在内网中继时用)' } as Record<string, string>,
|
||||
username: '用户名',
|
||||
usernameHint: '服务器不要求登录时留空',
|
||||
password: '口令',
|
||||
passwordSet: '已设置;留空表示不修改',
|
||||
passwordUnset: '未设置',
|
||||
passwordClear: '清除已保存的口令',
|
||||
fromAddr: '发件地址',
|
||||
fromName: '发件人名称',
|
||||
defaultAssignees: '默认发邮件给待办处理人',
|
||||
defaultAssigneesHint: '打开后,第一次有待办派给某人时,自动给他建一个个人邮件渠道(订阅待办打开、动作结果未知、审核有结果),发到他的账号邮箱;本人之后可以改订阅或关掉、删掉。缺省关:刚配好邮件服务时不会突然往外发邮件',
|
||||
save: '保存邮件服务',
|
||||
saved: '已保存邮件服务设置',
|
||||
invalidPort: '请填写 1 到 65535 之间的整数',
|
||||
leaveBody: '邮件服务有没保存的改动,离开将丢失这些改动。',
|
||||
test: '发测试邮件',
|
||||
testHint: '按已保存的设置发一封到你自己的账号邮箱',
|
||||
testDirty: '先保存再发测试邮件',
|
||||
handoff: '已交给邮件服务器',
|
||||
handoffHint: '「已交给邮件服务器」不等于已经进了收件箱:退信不追踪,是否送达以邮件服务器为准',
|
||||
// 每天的待办汇总(服务端 notify/digest.py)
|
||||
digest: '每天的待办汇总',
|
||||
digestHint: '每天这个时间(按服务端所在时区)给订阅了「每天的待办汇总」的个人渠道发一条:还有几条待办、最早的时限、最多 5 条和去待办箱的链接。没有待办的人那天不发',
|
||||
digestAt: '发送时间',
|
||||
digestSave: '保存发送时间',
|
||||
digestSaved: (at: string) => `每天 ${at} 发待办汇总`,
|
||||
digestReadOnly: '只有管理员能修改发送时间',
|
||||
// 渠道里的邮件格式
|
||||
personalTo: (email: string | null) => (email ? `发到账号邮箱 ${email}` : '你的账号没有邮箱地址,发不出去:请联系管理员在「设置 → 身份」里填写'),
|
||||
personalToHint: '个人邮件渠道只发到你的账号邮箱,不能填别的地址',
|
||||
recipients: '收件地址',
|
||||
recipientsHint: '多个地址用逗号或换行分隔,最多 20 个;人多的请填邮件组的地址',
|
||||
to: (list: string[]) => (list.length
|
||||
? `发到 ${list.slice(0, 2).join('、')}${list.length > 2 ? ` 等 ${list.length} 个地址` : ''}` : ''),
|
||||
off: '邮件服务没有启用,暂时不能选邮件:请管理员在下面的「邮件服务」里配置并启用',
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享链接(服务端 api/shares.py,04 细案 C2):应用编辑 → 岗位与授权里的管理。叫「分享链接」,不叫快照的那种说法:
|
||||
* 「快照」专指查询快照
|
||||
*/
|
||||
export const SHARE_TEXT = {
|
||||
section: '分享链接',
|
||||
hint: '拿到链接的人不用登录就能看这一页:只读,只显示定时出数的视图,看到的数据和使用者一样遮过;出处给「下载报告」。必须设到期,随时可以撤销',
|
||||
off: '管理员还没有打开分享链接:在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」后才能建',
|
||||
offWithRows: '管理员已关闭分享链接:下面这些暂时打不开,仍然可以撤销',
|
||||
personal: '个人应用只给你自己用,不能分享',
|
||||
unpublished: '发布之后才能建分享链接',
|
||||
create: '新建分享链接',
|
||||
empty: '还没有分享链接',
|
||||
page: '分享哪一页',
|
||||
pageHint: '只显示这一页上定时出数的视图;入口、表单、实例、待办和报告正文在分享页上换成说明',
|
||||
mode: '数据',
|
||||
modeLatest: '始终最新:跟着最近一次封存走',
|
||||
modeFixed: '固定在现在这一期:之后出的数不换上来',
|
||||
modeShort: { latest: '始终最新', fixed: '固定这一期' } as Record<string, string>,
|
||||
expires: '有效期',
|
||||
expiresDays: (n: number) => `${n} 天`,
|
||||
expiresHint: (max: number) => `必须设到期,最长 ${max} 天`,
|
||||
embed: '允许嵌入到别的站点',
|
||||
embedHint: '勾上之后这条链接能放进门户的 iframe(只限部署配置里允许嵌入分享页的站点);不勾的在别的页面里不显示内容',
|
||||
embedNoOrigins: '部署没有配置允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS),勾了也只能在 AgentLab 自己的页面里嵌',
|
||||
submit: '建分享链接',
|
||||
created: '分享链接已建好',
|
||||
once: '链接只显示这一次,请现在复制下来;之后只看得到开头几位',
|
||||
dismiss: '我已复制',
|
||||
state: { active: '有效', revoked: '已撤销', expired: '已到期' } as Record<string, string>,
|
||||
embedded: '可嵌入',
|
||||
opened: (n: number) => (n ? `打开过 ${n} 次` : '还没打开过'),
|
||||
by: (who: string) => `由 ${who} 建`,
|
||||
until: (at: string) => `${at} 到期`,
|
||||
revoke: '撤销',
|
||||
revokeTitle: (prefix: string) => `撤销分享链接 ${prefix}…?`,
|
||||
revokeBody: '撤销之后,拿着这条链接的人打开会看到「链接已撤销」,不能恢复;要再分享请建一条新的。',
|
||||
revoked: '已撤销分享链接',
|
||||
}
|
||||
|
||||
/** 分享页(/s,不在外壳里):拿到分享链接的人看到的页面 */
|
||||
export const SHARE_PAGE_TEXT = {
|
||||
loading: '正在打开分享的页面…',
|
||||
readOnly: '只读',
|
||||
footer: (who: string, at: string, until: string) => `只读 · 由 ${who} 于 ${at} 分享 · ${until} 到期`,
|
||||
someone: '管理应用的人',
|
||||
fixed: '固定在分享时的那一期',
|
||||
latest: '跟着最近一次封存更新',
|
||||
stamps: '数据的来历',
|
||||
stampsHint: '每个视图取的是哪一次运行、截至什么时候、按哪个口径、出具档位',
|
||||
download: '下载报告',
|
||||
downloadHint: '下载的报告里每个数都点得开出处(口径、来源、计算方式),离线也能看',
|
||||
downloading: '正在下载…',
|
||||
downloaded: '已下载报告',
|
||||
noReports: '这一页的数据没有可下载的报告',
|
||||
evidenceTitle: '出处在下载的报告里',
|
||||
evidence: '分享页不打开在线出处:点下面的「下载报告」,下载的报告里每个数都点得开出处,离线也能看',
|
||||
close: '知道了',
|
||||
noKey: '链接不完整:缺少 # 后面的那一段,请向分享的人确认链接',
|
||||
errorTitle: {
|
||||
share_revoked: '链接已撤销', share_expired: '链接已到期', share_links_off: '分享链接已关闭',
|
||||
share_gone: '分享的页面打不开了', share_not_found: '链接无效', share_key_missing: '链接不完整',
|
||||
} as Record<string, string>,
|
||||
failed: '分享的页面打不开',
|
||||
framed: '这条分享链接没有允许嵌入到别的页面,请直接打开链接查看',
|
||||
openDirect: '在新窗口打开',
|
||||
}
|
||||
|
||||
@@ -682,6 +682,8 @@ function PrefsTab() {
|
||||
// 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关)
|
||||
appsDaily: string
|
||||
appsPersonal: boolean
|
||||
// 允许建分享链接(服务端 apps.share_links,04 细案 C2,默认关)
|
||||
appsShare: boolean
|
||||
} | null>(null)
|
||||
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
|
||||
// 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取
|
||||
@@ -735,6 +737,7 @@ function PrefsTab() {
|
||||
// 服务端没给这一项时按默认的 200 显示(同服务端的缺省值)
|
||||
appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT),
|
||||
appsPersonal: values?.apps?.user_personal_apps === true,
|
||||
appsShare: values?.apps?.share_links === true,
|
||||
}
|
||||
const cur = draft ?? saved
|
||||
const stepCap = Number(values?.limits?.max_agent_steps) || 100
|
||||
@@ -834,7 +837,8 @@ function PrefsTab() {
|
||||
const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null
|
||||
// 应用一组同样整组写:别的键(以后加的)照原样带回去
|
||||
const apps = appsChanged
|
||||
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal }
|
||||
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal,
|
||||
share_links: cur.appsShare }
|
||||
: null
|
||||
if (runChanged || judge || verify || catalogDraft || apps) {
|
||||
const run = {
|
||||
@@ -1201,6 +1205,17 @@ function PrefsTab() {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="mt-3 flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={cur.appsShare} data-apps-share-links=""
|
||||
aria-describedby="pref-apps-share-hint"
|
||||
onChange={(e) => edit({ appsShare: e.target.checked })} />
|
||||
<span>
|
||||
{APP_SETTING_TEXT.shareLinks}
|
||||
<span id="pref-apps-share-hint" className="mt-0.5 block text-2xs leading-relaxed text-faint">
|
||||
{APP_SETTING_TEXT.shareLinksHint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
</fieldset>
|
||||
</>
|
||||
@@ -1228,6 +1243,7 @@ function PrefsTab() {
|
||||
jClick: '每次点击的金额上限', jDaily: '每日金额上限',
|
||||
draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model,
|
||||
appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps,
|
||||
appsShare: APP_SETTING_TEXT.shareLinks,
|
||||
})[k]).concat(verifyLabels(verifyKeys)).join('、')}
|
||||
</span>
|
||||
</span>
|
||||
@@ -1261,7 +1277,7 @@ function PrefsTab() {
|
||||
}
|
||||
|
||||
/** 应用一组在草稿里的几项:保存时整组写 apps */
|
||||
const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal'])
|
||||
const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal', 'appsShare'])
|
||||
|
||||
/** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */
|
||||
const APPS_DAILY_DEFAULT = 200
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 分享页(/s#<分享 key>,不在外壳里,同登录页的写法;04 细案 C2):拿到分享链接的人不用登录就能看应用的一页。
|
||||
*
|
||||
* - key 在 # 后面:打开页面时不发给服务器、不进访问日志和 Referer,这里读出来放进请求头(api/shares.ts);
|
||||
* - 用同一个 ViewRenderer 渲染服务端按分享解析好的视图模型(不另写渲染器):preview 打开,所有操作禁用;入口、表单、实例、
|
||||
* 待办、报告正文服务端已经换成写明原因的说明块;
|
||||
* - 印章常显:页面上没放印章组件的视图,也在「数据的来历」里逐个列出它的印章(截至、口径、出具档位、来源);
|
||||
* - 出处给「下载报告」:点数字不开在线证据,说明出处在下载的报告里(报告文件自己离线点得开);
|
||||
* - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整,照服务端的话写明,不给空白页;
|
||||
* - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」(服务端在 /s 上只能按全站的嵌入白名单
|
||||
* 发头,打开页面那一刻它还不知道是哪一条分享)。
|
||||
*
|
||||
* 不认人、不取目录、不连心跳(App 在 /s 上整个跳过外壳的启动),也就不会因为没登录被送去登录页。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Download, ExternalLink, Lock } from 'lucide-react'
|
||||
import { ApiError } from '../../api/http'
|
||||
import { shares } from '../../api/shares'
|
||||
import { ViewRenderer } from '../../apps/ViewRenderer'
|
||||
import { Stamp } from '../../apps/Stamp'
|
||||
import { Logo } from '../../components/Logo'
|
||||
import { Modal, Notice, Spinner, toast } from '../../components/ui'
|
||||
import { formatDateTime } from '../../lib/format'
|
||||
import { SHARE_PAGE_TEXT as T } from '../../lib/termsApp'
|
||||
import type { SharePayload } from '../../typesApp'
|
||||
|
||||
/** 链接里 # 后面的那一段就是分享 key */
|
||||
export const shareKeyOf = (hash: string): string => decodeURIComponent(hash.replace(/^#/, '').trim())
|
||||
|
||||
const framed = (): boolean => {
|
||||
try {
|
||||
return window.self !== window.top
|
||||
} catch {
|
||||
// 跨源的父页面读 top 会抛:那就是被别的站点嵌着
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function SharePage() {
|
||||
const key = useMemo(() => shareKeyOf(window.location.hash), [])
|
||||
const [data, setData] = useState<SharePayload | null>(null)
|
||||
const [error, setError] = useState<{ code?: string; message: string } | null>(key ? null : { code: 'share_key_missing', message: T.noKey })
|
||||
const [note, setNote] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) return
|
||||
shares.page(key).then(setData, (e) => setError({
|
||||
code: e instanceof ApiError ? e.code : undefined,
|
||||
message: e instanceof Error ? e.message : T.failed,
|
||||
}))
|
||||
}, [key])
|
||||
useEffect(() => {
|
||||
if (data) document.title = `${data.view.app.name} · ${data.view.page.title || data.view.page.id}`
|
||||
}, [data])
|
||||
|
||||
const blocked = !!data && !data.share.embed_allowed && framed()
|
||||
const state = error ? 'error' : !data ? 'loading' : blocked ? 'framed' : 'ok'
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-bg" data-share-page={state}>
|
||||
<div className="mx-auto max-w-5xl space-y-4 p-4 sm:p-6">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<Logo size={26} className="text-accent" title="AgentLab" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-base font-semibold text-fg">
|
||||
{data ? data.view.app.name : 'AgentLab'}
|
||||
{data?.view.page.title && <span className="font-normal text-dim"> · {data.view.page.title}</span>}
|
||||
</h1>
|
||||
</div>
|
||||
{data && (
|
||||
<span className="chip flex items-center gap-1" data-share-readonly="">
|
||||
<Lock size={10} aria-hidden /> {T.readOnly} · {data.share.mode === 'fixed' ? T.fixed : T.latest}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border bg-panel p-4" data-share-error={error.code ?? ''}>
|
||||
<h2 className="text-sm font-semibold">{(error.code && T.errorTitle[error.code]) || T.failed}</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-dim" role="alert">{error.message}</p>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center gap-2 text-xs text-dim" role="status"><Spinner size={12} /> {T.loading}</div>
|
||||
) : blocked ? (
|
||||
<div className="rounded-lg border bg-panel p-4" data-share-framed="">
|
||||
<p className="text-xs leading-relaxed text-fg">{T.framed}</p>
|
||||
<a className="btn btn-sm mt-2" href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={11} aria-hidden /> {T.openDirect}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ViewRenderer model={data.view} appId={data.view.app.id} preview onEvidence={() => setNote(true)} />
|
||||
<Stamps data={data} />
|
||||
<footer className="space-y-2 border-t pt-3" data-share-footer="">
|
||||
<Downloads shareKey={key} data={data} />
|
||||
<p className="text-2xs text-faint" data-share-footer-text="">
|
||||
{T.footer(data.share.created_by || T.someone, formatDateTime(data.share.created_at),
|
||||
formatDateTime(data.share.expires_at))}
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<Modal open={note} onClose={() => setNote(false)} title={T.evidenceTitle} width={460}
|
||||
footer={<button type="button" className="btn" onClick={() => setNote(false)}>{T.close}</button>}>
|
||||
<div className="space-y-3" data-share-evidence-note="">
|
||||
<p className="text-xs leading-relaxed text-fg">{T.evidence}</p>
|
||||
<Downloads shareKey={key} data={data} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 印章常显:每个视图来源的印章(取到了运行的),页面上有没有放印章组件都列 */
|
||||
function Stamps({ data }: { data: SharePayload }) {
|
||||
const rows = Object.entries(data.view.sources).filter(([, s]) => s.stamp)
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section className="space-y-2" data-share-stamps={rows.length}>
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold">{T.stamps}</h2>
|
||||
<p className="text-2xs text-faint">{T.stampsHint}</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{rows.map(([vid, s]) => <Stamp key={vid} stamp={s.stamp!} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 下载报告:只给这条分享解析出来、带报告的那几次运行(服务端只认这几次) */
|
||||
function Downloads({ shareKey, data }: { shareKey: string; data: SharePayload }) {
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const items = data.downloads.flatMap((d) => d.reports.map((node) => ({ ...d, node })))
|
||||
if (!items.length) return <p className="text-2xs text-faint" data-share-no-reports="">{T.noReports}</p>
|
||||
const download = async (runId: string, node: string) => {
|
||||
const id = `${runId}:${node}`
|
||||
setBusy(id)
|
||||
try {
|
||||
const { blob, name } = await shares.report(shareKey, runId, node)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name ?? `报告-${runId.slice(0, 6)}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
toast.ok(T.downloaded)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((d) => {
|
||||
const id = `${d.run_id}:${d.node}`
|
||||
return (
|
||||
<button key={id} type="button" className="btn btn-sm" disabled={busy != null} data-share-download={d.run_id}
|
||||
onClick={() => void download(d.run_id, d.node)}>
|
||||
{busy === id ? <Spinner size={11} /> : <Download size={11} aria-hidden />}
|
||||
{busy === id ? T.downloading : T.download}
|
||||
{items.length > 1 && d.as_of_text && <span className="text-2xs text-faint">({d.as_of_text})</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Notice tone="info" attr={{ 'data-share-download-hint': '' }}>{T.downloadHint}</Notice>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { KeyRound, Plus } from 'lucide-react'
|
||||
import { KeyRound, Link2, Plus } from 'lucide-react'
|
||||
import { api } from '../../../api/client'
|
||||
import type { AppHookRow, HookReceipts } from '../../../typesApp'
|
||||
import type { AppHookRow, HookReceipts, ViewShareCreated, ViewShareList, ViewShareRow } from '../../../typesApp'
|
||||
import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests'
|
||||
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp'
|
||||
import { formatDateTime } from '../../../lib/format'
|
||||
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT, SHARE_TEXT as S } from '../../../lib/termsApp'
|
||||
import {
|
||||
CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast,
|
||||
} from '../../../components/ui'
|
||||
@@ -20,6 +21,8 @@ import { useEditor } from './context'
|
||||
* 岗位:图里人工节点写的岗位名(assignee 的岗位)在这里绑定到人或组;绑定不进 spec,存在草稿的 bindings 里,发布时
|
||||
* 进应用版本——spec 可以导出,绑定是这一个部署自己的。人员变动只改组成员,不发新版本。
|
||||
* 授权:use / observe / manage,即时生效、写审计,不需要发新版本(A1)。关登录时所有人都是管理员,授权开登录后才生效
|
||||
* 分享链接(04 细案 C2):管理员开了「允许建分享链接」后,管理应用的人给发布版的一页建只读、不用登录的链接(必设到期、
|
||||
* 最长 90 天、可撤销);链接只在建好时显示一次。关着时不给新建,已有的照列、能撤销
|
||||
*/
|
||||
|
||||
const ROLES: GrantRole[] = ['use', 'observe', 'manage']
|
||||
@@ -73,6 +76,169 @@ export function AccessSection() {
|
||||
</div>
|
||||
</section>
|
||||
<Grants />
|
||||
<Shares />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 有效期的几个选项(天),按服务端的上限截 */
|
||||
const SHARE_DAYS = [1, 7, 30, 90]
|
||||
|
||||
function Shares() {
|
||||
const { appId, detail } = useEditor()
|
||||
const personal = detail.scope === 'personal'
|
||||
const pages = detail.published?.pages ?? []
|
||||
const [list, setList] = useState<ViewShareList | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [form, setForm] = useState<{ page: string; mode: 'latest' | 'fixed'; days: number; embed: boolean } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [made, setMade] = useState<ViewShareCreated | null>(null)
|
||||
const load = useCallback(() => {
|
||||
if (personal) return
|
||||
api.apps.shares(appId).then((v) => { setList(v); setError(null) }, setError)
|
||||
}, [appId, personal])
|
||||
useEffect(load, [load])
|
||||
|
||||
if (personal) {
|
||||
return (
|
||||
<section data-app-shares="personal">
|
||||
<SectionBar title={S.section} hint={S.personal} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const create = async () => {
|
||||
if (!form || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.apps.createShare(appId, {
|
||||
page_id: form.page, mode: form.mode, embed_allowed: form.embed,
|
||||
expires_at: new Date(Date.now() + form.days * 86_400_000).toISOString(),
|
||||
})
|
||||
setMade(out)
|
||||
setForm(null)
|
||||
load()
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const revoke = async (row: ViewShareRow) => {
|
||||
if (!(await confirmDialog({ title: S.revokeTitle(row.prefix), body: S.revokeBody, danger: true, confirmLabel: S.revoke }))) return
|
||||
try {
|
||||
await api.apps.revokeShare(appId, row.id)
|
||||
toast.ok(S.revoked)
|
||||
load()
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
}
|
||||
}
|
||||
const maxDays = list?.max_days ?? 90
|
||||
const canCreate = !!list?.enabled && pages.length > 0 && detail.published_version != null
|
||||
const hint = !list ? S.hint : !list.enabled ? (list.items.length ? S.offWithRows : S.off)
|
||||
: detail.published_version == null ? S.unpublished : S.hint
|
||||
return (
|
||||
<section data-app-shares={list ? (list.enabled ? 'on' : 'off') : ''}>
|
||||
<SectionBar title={S.section} hint={hint}>
|
||||
{canCreate && !form && (
|
||||
<button type="button" className="btn btn-sm" data-app-share-create=""
|
||||
onClick={() => { setMade(null); setForm({ page: pages[0].id, mode: 'latest', days: Math.min(30, maxDays), embed: false }) }}>
|
||||
<Plus size={11} aria-hidden /> {S.create}
|
||||
</button>
|
||||
)}
|
||||
</SectionBar>
|
||||
{made && <ShareOnce made={made} onDismiss={() => setMade(null)} />}
|
||||
{form && (
|
||||
<div className="mb-3 space-y-3 rounded-lg border bg-panel px-3 py-3" data-app-share-form="">
|
||||
<Field label={S.page} hint={S.pageHint}>
|
||||
{(p) => (
|
||||
<select {...p} className="field max-w-72" value={form.page} data-app-share-page=""
|
||||
onChange={(e) => setForm({ ...form, page: e.target.value })}>
|
||||
{pages.map((pg) => <option key={pg.id} value={pg.id}>{pg.title || pg.id}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<fieldset>
|
||||
<legend className="label">{S.mode}</legend>
|
||||
{(['latest', 'fixed'] as const).map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" name="share-mode" checked={form.mode === m} data-app-share-mode={m}
|
||||
onChange={() => setForm({ ...form, mode: m })} />
|
||||
{m === 'latest' ? S.modeLatest : S.modeFixed}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<Field label={S.expires} hint={S.expiresHint(maxDays)} required>
|
||||
{(p) => (
|
||||
<select {...p} className="field w-32" value={form.days} data-app-share-days=""
|
||||
onChange={(e) => setForm({ ...form, days: Number(e.target.value) })}>
|
||||
{SHARE_DAYS.filter((d) => d <= maxDays).map((d) => <option key={d} value={d}>{S.expiresDays(d)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={form.embed} data-app-share-embed=""
|
||||
onChange={(e) => setForm({ ...form, embed: e.target.checked })} />
|
||||
<span>
|
||||
{S.embed}
|
||||
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{list?.embed_origins ? S.embedHint : S.embedNoOrigins}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>取消</button>
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={busy} onClick={() => void create()} data-app-share-submit="">
|
||||
{busy ? <Spinner size={11} /> : <Link2 size={11} aria-hidden />} {S.submit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error ? <ErrorState error={error} onRetry={load} /> : !list ? <Skeleton rows={2} height={36} /> : !list.items.length ? (
|
||||
<p className="text-2xs text-faint" data-app-shares-empty="">{S.empty}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{list.items.map((row) => (
|
||||
<div key={row.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-panel px-3 py-2 text-xs"
|
||||
data-app-share={row.prefix} data-app-share-state={row.state}>
|
||||
<span className="mono">{row.prefix}…</span>
|
||||
<span className="font-medium">{row.page_title}</span>
|
||||
<span className="chip">{S.modeShort[row.mode] ?? row.mode}</span>
|
||||
<span className="chip" style={row.state === 'active' ? { color: 'var(--ok)' } : { color: 'var(--st-waiting)' }}>
|
||||
{S.state[row.state] ?? row.state}
|
||||
</span>
|
||||
{row.embed_allowed && <span className="chip">{S.embedded}</span>}
|
||||
<span className="min-w-0 flex-1 truncate text-2xs text-faint">
|
||||
{[row.expires_at ? S.until(formatDateTime(row.expires_at)) : null, S.opened(row.open_count),
|
||||
row.created_by_name || row.created_by ? S.by(row.created_by_name || row.created_by || '') : null]
|
||||
.filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
{row.state === 'active' && (
|
||||
<button type="button" className="btn btn-xs" onClick={() => void revoke(row)} data-app-share-revoke={row.prefix}>
|
||||
{S.revoke}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 刚建好的分享链接:只显示这一次(库里只有哈希),附复制按钮;关掉就没有了 */
|
||||
function ShareOnce({ made, onDismiss }: { made: ViewShareCreated; onDismiss: () => void }) {
|
||||
const link = made.url ?? `${window.location.origin}${made.path}`
|
||||
return (
|
||||
<div className="mb-3 space-y-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--st-waiting)' }} role="status"
|
||||
data-app-share-once="">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold"><Link2 size={12} aria-hidden /> {S.created}</div>
|
||||
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>{S.once}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="mono min-w-0 flex-1 break-all rounded border bg-bg px-2 py-1 text-2xs" data-app-share-link="">{link}</code>
|
||||
<CopyButton text={link} />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" className="btn btn-sm" onClick={onDismiss} data-app-share-dismiss="">{S.dismiss}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Bell, Plus, Send } from 'lucide-react'
|
||||
import { Bell, Check, Mail, Plus, Send } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import {
|
||||
DeleteButton, EmptyState, ErrorState, Field, Modal, SectionBar, Skeleton, Spinner, confirmDialog, toast,
|
||||
} from '../../components/ui'
|
||||
import { formatDateTime } from '../../lib/format'
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms'
|
||||
import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp'
|
||||
import { MAIL_TEXT as M, NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp'
|
||||
import { useAtLeast } from '../../store/auth'
|
||||
import type { NotifyChannelRow, NotifyEventRow } from '../../typesApp'
|
||||
import type { NotifyChannelRow, NotifyEventRow, NotifyMailState } from '../../typesApp'
|
||||
|
||||
/*
|
||||
* 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式只有 Webhook 的四种(Q6),发送核心和定时任务的
|
||||
* 通知共用;消息里只放标题、状态和链接,不放报告里的数字。地址和签名密钥加密保存,之后只显示主机名。
|
||||
* 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式是 Webhook 的四种加上邮件(04 细案 B2,改写了 Q6):
|
||||
* Webhook 的发送核心和定时任务的通知共用;邮件走管理员在「邮件服务」一节配的 SMTP。消息里只放标题、状态和链接,不放报告
|
||||
* 里的数字。地址和签名密钥加密保存,之后只显示主机名。
|
||||
* 全局渠道只给管理员建;个人渠道每个人都能建(§3.12)。开了登录的使用者只看、只建自己的个人渠道(scope=user),
|
||||
* 投递记录只给搭建者以上
|
||||
* 投递记录只给搭建者以上。
|
||||
*
|
||||
* 邮件:邮件服务没启用时不列「邮件」这一格式(建了也发不出去);个人邮件渠道不填地址,只发到本人的账号邮箱(列表里写
|
||||
* 「发到账号邮箱 …」,没有邮箱照实写发不出去);全局、应用的填固定收件地址。投递成功写「已交给邮件服务器」,不写已送达:
|
||||
* 退信不追踪。「邮件服务」「每天的待办汇总」两节给搭建者以上看(设置接口搭建者只读),只有管理员能改。
|
||||
*/
|
||||
|
||||
const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const
|
||||
const WEBHOOK_FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const
|
||||
const EVENTS = Object.keys(T.eventLabel)
|
||||
/** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */
|
||||
const eventLabel = (event: string, scope: string): string =>
|
||||
(scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event
|
||||
|
||||
/** 一个渠道发到哪:Webhook 只有主机名;邮件写收件人(个人渠道是账号邮箱) */
|
||||
function targetOf(row: NotifyChannelRow): string {
|
||||
if (row.format !== 'email') return row.url_host
|
||||
if (row.scope === 'user') return M.personalTo(row.mail_to?.[0] ?? null)
|
||||
return M.to(row.mail_to ?? [])
|
||||
}
|
||||
|
||||
export function NotifyTab() {
|
||||
const builder = useAtLeast('builder')
|
||||
const [rows, setRows] = useState<NotifyChannelRow[] | null>(null)
|
||||
const [mail, setMail] = useState<NotifyMailState>({ enabled: false, account_email: null })
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [editing, setEditing] = useState<NotifyChannelRow | 'new' | null>(null)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const load = useCallback(() => {
|
||||
api.notify.channels()
|
||||
.then(({ items }) => { setRows(builder ? items : items.filter((c) => c.scope === 'user')); setError(null) })
|
||||
.then(({ items, mail: state }) => {
|
||||
setRows(builder ? items : items.filter((c) => c.scope === 'user'))
|
||||
setMail(state ?? { enabled: false, account_email: null })
|
||||
setError(null)
|
||||
})
|
||||
.catch(setError)
|
||||
}, [builder])
|
||||
useEffect(load, [load])
|
||||
@@ -40,7 +58,8 @@ export function NotifyTab() {
|
||||
setTesting(row.id)
|
||||
try {
|
||||
const res = await api.notify.testChannel(row.id)
|
||||
if (res.ok) toast.ok(T.testOk)
|
||||
// 邮件渠道成功时服务端说的是「已交给邮件服务器……」:照原话写,不写「已发送」
|
||||
if (res.ok) toast.ok(row.format === 'email' ? res.message : T.testOk)
|
||||
else toast.warn(res.message)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
@@ -79,7 +98,8 @@ export function NotifyTab() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2 rounded-lg border bg-panel px-3 py-2" data-notify-channel={row.name}>
|
||||
<div key={row.id} className="flex items-center gap-2 rounded-lg border bg-panel px-3 py-2" data-notify-channel={row.name}
|
||||
data-notify-format={row.format}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-medium">{row.name}</span>
|
||||
@@ -89,7 +109,10 @@ export function NotifyTab() {
|
||||
{!row.enabled && <span className="chip text-faint">已停用</span>}
|
||||
</div>
|
||||
<div className="truncate text-2xs text-faint">
|
||||
<span className="mono">{row.url_host}</span>
|
||||
<span className={row.format === 'email' ? undefined : 'mono'} data-notify-target=""
|
||||
style={row.format === 'email' && row.scope === 'user' && !row.mail_to?.length ? { color: 'var(--warn)' } : undefined}>
|
||||
{targetOf(row)}
|
||||
</span>
|
||||
{row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +131,9 @@ export function NotifyTab() {
|
||||
)}
|
||||
</section>
|
||||
{builder && <Deliveries />}
|
||||
{editing && <ChannelEditor row={editing === 'new' ? null : editing} onClose={() => setEditing(null)}
|
||||
{builder && <MailSection onSaved={load} />}
|
||||
{builder && <DigestSection />}
|
||||
{editing && <ChannelEditor row={editing === 'new' ? null : editing} mail={mail} onClose={() => setEditing(null)}
|
||||
onSaved={(name) => { setEditing(null); toast.ok(T.saved(name)); load() }} />}
|
||||
</div>
|
||||
)
|
||||
@@ -123,11 +148,13 @@ function Deliveries() {
|
||||
{!rows ? <Skeleton rows={2} height={28} /> : !rows.length ? <p className="text-2xs text-faint">{T.deliveriesEmpty}</p> : (
|
||||
<div className="divide-y rounded-lg border text-2xs">
|
||||
{rows.map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<div key={d.id} className="flex items-center gap-2 px-3 py-1.5" data-delivery-format={d.format ?? ''}>
|
||||
<span className="w-28 shrink-0 text-dim">{T.deliveryEventLabel[d.event] ?? T.eventLabel[d.event] ?? d.event}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-faint">{d.error ?? (d.http_status ? `HTTP ${d.http_status}` : '')}</span>
|
||||
<span className="shrink-0" style={{ color: d.status === 'ok' ? 'var(--ok)' : d.status === 'failed' ? 'var(--st-failed)' : undefined }}>
|
||||
{d.status === 'ok' ? '已送达' : d.status === 'failed' ? '失败' : d.status}
|
||||
{/* 邮件只知道交给了邮件服务器,进没进收件箱不知道(退信不追踪):不写「已送达」 */}
|
||||
<span className="shrink-0" title={d.format === 'email' && d.status === 'ok' ? M.handoffHint : undefined}
|
||||
style={{ color: d.status === 'ok' ? 'var(--ok)' : d.status === 'failed' ? 'var(--st-failed)' : undefined }}>
|
||||
{d.status === 'ok' ? (d.format === 'email' ? M.handoff : '已送达') : d.status === 'failed' ? '失败' : d.status}
|
||||
</span>
|
||||
<time className="tnum shrink-0 text-faint">{formatDateTime(d.created_at ?? d.at)}</time>
|
||||
</div>
|
||||
@@ -138,14 +165,16 @@ function Deliveries() {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelEditor({ row, onClose, onSaved }: {
|
||||
row: NotifyChannelRow | null; onClose: () => void; onSaved: (name: string) => void
|
||||
function ChannelEditor({ row, mail, onClose, onSaved }: {
|
||||
row: NotifyChannelRow | null; mail: NotifyMailState; onClose: () => void; onSaved: (name: string) => void
|
||||
}) {
|
||||
const admin = useAtLeast('admin')
|
||||
const builder = useAtLeast('builder')
|
||||
const [form, setForm] = useState({
|
||||
name: row?.name ?? '', scope: row?.scope ?? (admin ? 'global' : 'user'), format: row?.format ?? 'generic',
|
||||
url: '', secret: '', events: row?.events ?? ['task.opened', 'action.unknown'],
|
||||
// 全局、应用的邮件渠道:收件地址整份重填(服务端照原样给,编辑时带出来)
|
||||
recipients: row?.format === 'email' && row.scope !== 'user' ? (row.mail_to ?? []).join('\n') : '',
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 列哪些事件由服务端说(notify/service.EVENTS 的 scopes):这种范围的渠道收得到的才列,登记了还没推送的不列(勾了收不到)。
|
||||
@@ -155,18 +184,28 @@ function ChannelEditor({ row, onClose, onSaved }: {
|
||||
useEffect(() => { api.notify.events().then(setRegistry).catch(() => setRegistryFailed(true)) }, [])
|
||||
const offered = EVENTS.filter((ev) => form.events.includes(ev) || row?.events.includes(ev)
|
||||
|| registry?.some((r) => r.event === ev && r.scopes.includes(form.scope)))
|
||||
const email = form.format === 'email'
|
||||
// 邮件服务没启用:不列「邮件」(建了也发不出去);原来就是邮件渠道的照列,能改名、改订阅、换回 Webhook
|
||||
const formats = [...WEBHOOK_FORMATS, ...(mail.enabled || row?.format === 'email' ? ['email' as const] : [])]
|
||||
const personalMail = email && form.scope === 'user'
|
||||
const recipientsChanged = form.recipients.trim() !== (row?.format === 'email' ? (row.mail_to ?? []).join('\n') : '')
|
||||
const missingTarget = email ? !personalMail && !form.recipients.trim()
|
||||
: !row || row.format === 'email' ? !form.url.trim() : false
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const target = email
|
||||
? (personalMail ? {} : recipientsChanged || row?.format !== 'email' ? { url: form.recipients.trim() } : {})
|
||||
: form.url.trim() ? { url: form.url.trim() } : {}
|
||||
if (row) {
|
||||
await api.notify.updateChannel(row.id, {
|
||||
name: form.name.trim(), scope: form.scope, format: form.format, events: form.events,
|
||||
// 地址、密钥只回主机名:不填就是保持原值
|
||||
...(form.url.trim() ? { url: form.url.trim() } : {}), ...(form.secret ? { secret: form.secret } : {}),
|
||||
...target, ...(!email && form.secret ? { secret: form.secret } : {}),
|
||||
})
|
||||
} else {
|
||||
await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, url: form.url.trim(),
|
||||
secret: form.secret || null, events: form.events })
|
||||
await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, ...target,
|
||||
secret: !email && form.secret ? form.secret : null, events: form.events })
|
||||
}
|
||||
onSaved(form.name.trim())
|
||||
} catch (e) {
|
||||
@@ -179,7 +218,7 @@ function ChannelEditor({ row, onClose, onSaved }: {
|
||||
<Modal open onClose={onClose} title={row ? `${T.title}:${row.name}` : T.create} width={560} dirty
|
||||
footer={<>
|
||||
<button className="btn" onClick={onClose}>取消</button>
|
||||
<button className="btn btn-primary" disabled={busy || !form.name.trim() || (!row && !form.url.trim())}
|
||||
<button className="btn btn-primary" disabled={busy || !form.name.trim() || missingTarget}
|
||||
onClick={() => void save()} data-notify-save="">
|
||||
{busy ? <Spinner size={11} /> : null} 保存
|
||||
</button>
|
||||
@@ -191,9 +230,9 @@ function ChannelEditor({ row, onClose, onSaved }: {
|
||||
</Field>
|
||||
<Field label={T.format}>
|
||||
{(p) => (
|
||||
<select {...p} className="field" value={form.format}
|
||||
<select {...p} className="field" value={form.format} data-notify-format-select=""
|
||||
onChange={(e) => setForm({ ...form, format: e.target.value as typeof form.format })}>
|
||||
{FORMATS.map((f) => <option key={f} value={f}>{T.formats[f]}</option>)}
|
||||
{formats.map((f) => <option key={f} value={f}>{T.formats[f]}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
@@ -207,14 +246,32 @@ function ChannelEditor({ row, onClose, onSaved }: {
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={T.url} hint={row ? `${T.urlHint};当前:${row.url_host}` : T.urlHint} required={!row}>
|
||||
{(p) => <input {...p} className="field mono text-xs" value={form.url} placeholder="https://"
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })} />}
|
||||
</Field>
|
||||
<Field label={T.secret}>
|
||||
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="off" value={form.secret}
|
||||
onChange={(e) => setForm({ ...form, secret: e.target.value })} />}
|
||||
</Field>
|
||||
{!mail.enabled && !email && <p className="text-2xs text-faint" data-notify-mail-off="">{M.off}</p>}
|
||||
{personalMail ? (
|
||||
<div className="rounded-md border bg-elev px-2.5 py-2 text-xs" data-notify-mail-to="account">
|
||||
<div className="flex items-center gap-1.5" style={mail.account_email ? undefined : { color: 'var(--warn)' }}>
|
||||
<Mail size={12} aria-hidden /> {M.personalTo(mail.account_email)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-2xs text-faint">{M.personalToHint}</p>
|
||||
</div>
|
||||
) : email ? (
|
||||
<Field label={M.recipients} hint={M.recipientsHint} required>
|
||||
{(p) => <textarea {...p} className="field mono min-h-[72px] text-xs" value={form.recipients} data-notify-recipients=""
|
||||
placeholder="ops@example.com" onChange={(e) => setForm({ ...form, recipients: e.target.value })} />}
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label={T.url} hint={row && row.format !== 'email' ? `${T.urlHint};当前:${row.url_host}` : T.urlHint}
|
||||
required={!row || row.format === 'email'}>
|
||||
{(p) => <input {...p} className="field mono text-xs" value={form.url} placeholder="https://"
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })} />}
|
||||
</Field>
|
||||
<Field label={T.secret}>
|
||||
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="off" value={form.secret}
|
||||
onChange={(e) => setForm({ ...form, secret: e.target.value })} />}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend className="label">{T.events}</legend>
|
||||
<div className="grid grid-cols-3 gap-1.5" data-notify-events={registry ? form.scope : ''}>
|
||||
@@ -233,3 +290,200 @@ function ChannelEditor({ row, onClose, onSaved }: {
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 邮件服务(设置的 mail 一组)、每天的待办汇总(notify 一组的 digest_at)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
interface MailSettings {
|
||||
enabled: boolean; host: string | null; port: number; security: string; username: string | null
|
||||
password: null; password_set: boolean; from_addr: string | null; from_name: string; default_for_assignees: boolean
|
||||
}
|
||||
interface MailDraft {
|
||||
enabled: boolean; host: string; port: string; security: string; username: string; password: string
|
||||
clearPassword: boolean; from_addr: string; from_name: string; default_for_assignees: boolean
|
||||
}
|
||||
|
||||
const draftOf = (m: MailSettings): MailDraft => ({
|
||||
enabled: m.enabled, host: m.host ?? '', port: String(m.port ?? 587), security: m.security ?? 'starttls',
|
||||
username: m.username ?? '', password: '', clearPassword: false, from_addr: m.from_addr ?? '',
|
||||
from_name: m.from_name ?? 'AgentLab', default_for_assignees: m.default_for_assignees,
|
||||
})
|
||||
|
||||
/**
|
||||
* 「邮件服务」一节:管理员改,搭建者只看。口令不回,只说设没设;留空表示不修改,勾「清除」才去掉。保存一整组(服务端
|
||||
* 补齐没给的);测试邮件按已保存的设置发到管理员自己的账号邮箱,有没保存的改动时先保存
|
||||
*/
|
||||
function MailSection({ onSaved }: { onSaved: () => void }) {
|
||||
const admin = useAtLeast('admin')
|
||||
const [saved, setSaved] = useState<MailSettings | null>(null)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
const [draft, setDraft] = useState<MailDraft | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const load = () => api.settings.get().then((s) => { setSaved(s.mail ?? null); setLoadError(null) }, setLoadError)
|
||||
useEffect(() => { void load() }, [])
|
||||
const base = saved ? draftOf(saved) : null
|
||||
const cur = draft ?? base
|
||||
const dirty = !!cur && !!base && JSON.stringify(cur) !== JSON.stringify(base)
|
||||
useLeaveGuard(dirty, () => confirmDialog({
|
||||
title: M.section, body: M.leaveBody, confirmLabel: '放弃修改', cancelLabel: '返回保存', danger: true,
|
||||
}))
|
||||
const portBad = !!cur && !(/^\d+$/.test(cur.port.trim()) && Number(cur.port) >= 1 && Number(cur.port) <= 65535)
|
||||
const edit = (patch: Partial<MailDraft>) => cur && setDraft({ ...cur, ...patch })
|
||||
|
||||
const save = async () => {
|
||||
if (!cur || portBad || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.settings.put({ mail: {
|
||||
enabled: cur.enabled, host: cur.host.trim() || null, port: Number(cur.port), security: cur.security,
|
||||
username: cur.username.trim() || null, from_addr: cur.from_addr.trim() || null, from_name: cur.from_name.trim() || null,
|
||||
default_for_assignees: cur.default_for_assignees,
|
||||
...(cur.clearPassword ? { password: '' } : cur.password ? { password: cur.password } : {}),
|
||||
} })
|
||||
setSaved(out.mail)
|
||||
setDraft(null)
|
||||
toast.ok(M.saved)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const test = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const res = await api.settings.mailTest()
|
||||
if (res.ok) toast.ok(res.message)
|
||||
else toast.warn(res.message)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-mail-section={admin ? 'edit' : 'readonly'}>
|
||||
<SectionBar title={M.section} hint={admin ? M.hint : `${M.hint}。${M.readOnly}`} />
|
||||
{!cur ? (loadError ? <ErrorState error={loadError} onRetry={() => void load()} /> : <Skeleton rows={3} height={14} />) : (
|
||||
<fieldset disabled={!admin} className="space-y-3">
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={cur.enabled} data-mail-field="enabled"
|
||||
onChange={(e) => edit({ enabled: e.target.checked })} />
|
||||
<span>{M.enabled}<span className="mt-0.5 block text-2xs text-faint">{M.enabledHint}</span></span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label={M.host} hint={M.hostHint} className="col-span-2">
|
||||
{(p) => <input {...p} className="field mono text-xs" value={cur.host} placeholder="smtp.example.com"
|
||||
data-mail-field="host" onChange={(e) => edit({ host: e.target.value })} />}
|
||||
</Field>
|
||||
<Field label={M.port} error={portBad ? M.invalidPort : undefined}>
|
||||
{(p) => <input {...p} className="field tnum text-xs" inputMode="numeric" value={cur.port} data-mail-field="port"
|
||||
onChange={(e) => edit({ port: e.target.value })} />}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={M.security}>
|
||||
{(p) => (
|
||||
<select {...p} className="field max-w-72 text-xs" value={cur.security} data-mail-field="security"
|
||||
onChange={(e) => edit({ security: e.target.value })}>
|
||||
{Object.entries(M.securityLabel).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={M.username} hint={M.usernameHint}>
|
||||
{(p) => <input {...p} className="field mono text-xs" value={cur.username} autoComplete="off" data-mail-field="username"
|
||||
onChange={(e) => edit({ username: e.target.value })} />}
|
||||
</Field>
|
||||
<Field label={M.password} hint={saved?.password_set ? M.passwordSet : M.passwordUnset}>
|
||||
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="new-password" value={cur.password}
|
||||
data-mail-field="password" disabled={cur.clearPassword}
|
||||
onChange={(e) => edit({ password: e.target.value })} />}
|
||||
</Field>
|
||||
</div>
|
||||
{saved?.password_set && (
|
||||
<label className="flex items-center gap-1.5 text-2xs text-dim">
|
||||
<input type="checkbox" checked={cur.clearPassword} data-mail-field="clear-password"
|
||||
onChange={(e) => edit({ clearPassword: e.target.checked, password: '' })} /> {M.passwordClear}
|
||||
</label>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={M.fromAddr}>
|
||||
{(p) => <input {...p} className="field mono text-xs" value={cur.from_addr} placeholder="agentlab@example.com"
|
||||
data-mail-field="from_addr" onChange={(e) => edit({ from_addr: e.target.value })} />}
|
||||
</Field>
|
||||
<Field label={M.fromName}>
|
||||
{(p) => <input {...p} className="field text-xs" value={cur.from_name} data-mail-field="from_name"
|
||||
onChange={(e) => edit({ from_name: e.target.value })} />}
|
||||
</Field>
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={cur.default_for_assignees} data-mail-field="default_for_assignees"
|
||||
onChange={(e) => edit({ default_for_assignees: e.target.checked })} />
|
||||
<span>{M.defaultAssignees}<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{M.defaultAssigneesHint}</span></span>
|
||||
</label>
|
||||
{admin && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={!dirty || portBad || busy} onClick={() => void save()}
|
||||
data-mail-save="">
|
||||
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {M.save}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={dirty || testing} onClick={() => void test()}
|
||||
title={dirty ? M.testDirty : M.testHint} data-mail-test="">
|
||||
{testing ? <Spinner size={11} /> : <Send size={11} aria-hidden />} {M.test}
|
||||
</button>
|
||||
<span className="text-2xs text-faint">{dirty ? M.testDirty : M.testHint}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-2xs leading-relaxed text-faint" data-mail-handoff="">{M.handoffHint}</p>
|
||||
</fieldset>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 「每天的待办汇总」:几点发(服务端时区)。只改 notify 一组的这一项,服务端补上对外访问地址 */
|
||||
function DigestSection() {
|
||||
const admin = useAtLeast('admin')
|
||||
const [saved, setSaved] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEffect(() => { api.settings.get().then((s) => setSaved(s.notify?.digest_at ?? '09:00'), () => setSaved(null)) }, [])
|
||||
if (saved == null) return null
|
||||
const cur = draft ?? saved
|
||||
const valid = /^([01]\d|2[0-3]):[0-5]\d$/.test(cur)
|
||||
const save = async () => {
|
||||
if (!valid || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.settings.put({ notify: { digest_at: cur } })
|
||||
setSaved(out.notify?.digest_at ?? cur)
|
||||
setDraft(null)
|
||||
toast.ok(M.digestSaved(out.notify?.digest_at ?? cur))
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section data-digest-section={admin ? 'edit' : 'readonly'}>
|
||||
<SectionBar title={M.digest} hint={admin ? M.digestHint : `${M.digestHint}。${M.digestReadOnly}`} />
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label={M.digestAt}>
|
||||
{(p) => <input {...p} type="time" className="field tnum w-32 text-xs" value={cur} disabled={!admin} data-digest-at=""
|
||||
onChange={(e) => setDraft(e.target.value)} />}
|
||||
</Field>
|
||||
{admin && (
|
||||
<button type="button" className="btn btn-sm" disabled={cur === saved || !valid || busy} onClick={() => void save()}
|
||||
data-digest-save="">
|
||||
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {M.digestSave}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -456,6 +456,11 @@ export interface ViewNode {
|
||||
* 使用面不下发隐藏的组件
|
||||
*/
|
||||
hidden?: boolean
|
||||
/**
|
||||
* 分享链接里(resolve_page 的 share 旁路)被换成说明块的组件原来是什么(entry_button、task_list、report……):type 已经是
|
||||
* notice,props.text 写着为什么不显示。别处没有这一项
|
||||
*/
|
||||
share_placeholder?: string
|
||||
/**
|
||||
* 服务端解析好的数据(backend/app/apps/resolve.py 的 _data),形状随组件而定;绑定了视图的都带 state 和可选的 message:
|
||||
* - stamp:{view, state, stamp};metric:{state, text, name, unit, evidence}
|
||||
@@ -720,12 +725,79 @@ export interface NotifyChannelRow {
|
||||
name: string
|
||||
scope: 'global' | 'app' | 'user'
|
||||
owner_id: string | null
|
||||
format: 'generic' | 'feishu' | 'wecom' | 'dingtalk'
|
||||
/** 只回主机名 */
|
||||
/** email:邮件(第五种格式,服务端 notify/mail.py,04 细案 B2) */
|
||||
format: 'generic' | 'feishu' | 'wecom' | 'dingtalk' | 'email'
|
||||
/** 只回主机名;邮件渠道是空串 */
|
||||
url_host: string
|
||||
signed: boolean
|
||||
events: string[]
|
||||
enabled: boolean
|
||||
/**
|
||||
* 邮件渠道发给谁:个人渠道是拥有者的账号邮箱(没有邮箱是空的,发不出去),全局、应用的是填的那几个收件地址。
|
||||
* Webhook 渠道没有这一项。全局、应用的邮件渠道只给改得了它的人(开了登录要搭建者以上),别人拿到的是 mail_count
|
||||
*/
|
||||
mail_to?: string[]
|
||||
/** 全局、应用的邮件渠道给改不了它的人列出时:只有收件地址的个数,没有地址(设置页的通知只给使用者列个人渠道,用不上) */
|
||||
mail_count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/notify/channels 另给的 mail:邮件服务(设置的 mail 一组)启没启用、本人的账号邮箱(关登录时为 null)。
|
||||
* 设置页据此列不列「邮件」这一格式、个人邮件渠道写「发到账号邮箱 …」。老服务端不给时按没启用
|
||||
*/
|
||||
export interface NotifyMailState {
|
||||
enabled: boolean
|
||||
account_email: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享链接(服务端 api/shares.py,04 细案 C2):管理应用的人在应用编辑里建、撤销。key 只在建的时候交回一次
|
||||
* (ViewShareCreated),之后只给开头一截(prefix)认是哪一条
|
||||
*/
|
||||
export interface ViewShareRow {
|
||||
id: string
|
||||
prefix: string
|
||||
app_id: string
|
||||
page_id: string
|
||||
page_title: string
|
||||
/** latest:始终是最近一次封存;fixed:固定在建分享那一刻每个视图取的运行 */
|
||||
mode: 'latest' | 'fixed'
|
||||
app_version: number | null
|
||||
embed_allowed: boolean
|
||||
created_by: string | null
|
||||
created_by_name: string | null
|
||||
created_at: string | null
|
||||
expires_at: string | null
|
||||
revoked_at: string | null
|
||||
revoked_by: string | null
|
||||
open_count: number
|
||||
last_opened_at: string | null
|
||||
state: 'active' | 'revoked' | 'expired'
|
||||
}
|
||||
|
||||
export interface ViewShareCreated extends ViewShareRow {
|
||||
key: string
|
||||
/** /s#<key> */
|
||||
path: string
|
||||
/** 拼上对外访问地址的完整链接;没设对外访问地址时为 null,界面按当前页面的地址拼 */
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export interface ViewShareList {
|
||||
/** 管理员开没开「允许建分享链接」:关着时不给新建,已有的照列、能撤销 */
|
||||
enabled: boolean
|
||||
/** 部署配没配允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS) */
|
||||
embed_origins: boolean
|
||||
max_days: number
|
||||
items: ViewShareRow[]
|
||||
}
|
||||
|
||||
/** 分享页(GET /api/shares/page,agentlab.share/1):页脚要的、按分享解析的视图模型、能下载报告的那几次运行 */
|
||||
export interface SharePayload {
|
||||
format: 'agentlab.share/1'
|
||||
share: { mode: 'latest' | 'fixed'; created_by: string | null; created_at: string; expires_at: string; embed_allowed: boolean }
|
||||
view: ViewModel
|
||||
downloads: { view: string; run_id: string; reports: string[]; as_of_text?: string | null }[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,6 +99,7 @@ const ORDER = [
|
||||
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
|
||||
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
|
||||
'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明
|
||||
'share', // 分享链接:/s 分享页不进外壳、印章常显、页脚、下载报告、撤销后的说明、嵌入;应用编辑里的分享管理
|
||||
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
|
||||
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
|
||||
'personal', // 个人应用:建、刷新、提交审核
|
||||
@@ -115,7 +116,7 @@ const ESTIMATE = {
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
|
||||
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
|
||||
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30,
|
||||
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, share: 30,
|
||||
// 普通用户建应用:还没实测,按同类页面估
|
||||
blueprint: 90, personal: 45, records: 30, files: 30,
|
||||
}
|
||||
|
||||
+127
-12
@@ -6,6 +6,9 @@
|
||||
// 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒);
|
||||
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
|
||||
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
|
||||
// - 邮件(04 细案 B2):「邮件服务」一节管理员改、搭建者只看,口令只说设没设、留空不改,有没保存的改动时不能发测试邮件;
|
||||
// 每天的待办汇总的发送时间;邮件服务没启用时不列「邮件」格式;个人邮件渠道不填地址、写「发到账号邮箱 …」,没有邮箱照实
|
||||
// 写发不出去;投递记录里邮件成功写「已交给邮件服务器」,不写已送达;
|
||||
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
|
||||
// - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6);
|
||||
// - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。
|
||||
@@ -86,7 +89,11 @@ const MCP_ROWS = [
|
||||
headers: { Authorization: 'Bearer ****9a2c' }, endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
|
||||
]
|
||||
|
||||
async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
const NOTIFY_ITEMS = [
|
||||
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
|
||||
events: ['task.opened'], enabled: true }]
|
||||
|
||||
async function open(path, { conflict = false, me = OFF, settings = null, notify = null, deliveries = [] } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
@@ -95,7 +102,8 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
const state = { caps: JSON.parse(JSON.stringify(CAPS)), endpoints: JSON.parse(JSON.stringify(ENDPOINTS)), governs: [], endpointWrites: [],
|
||||
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] }
|
||||
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [], channelWrites: [], mailTests: 0,
|
||||
values: settings ? JSON.parse(JSON.stringify(settings)) : { ui: { theme: 'dark' } } }
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
@@ -133,8 +141,25 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
return json(route, { id: 'm9', ...req.postDataJSON() })
|
||||
})
|
||||
await page.route(/\/api\/settings$/, (route) => {
|
||||
if (route.request().method() !== 'GET') { state.prefs.push(route.request().postDataJSON()); return json(route, {}) }
|
||||
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, { ui: { theme: 'dark' } })
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON()
|
||||
state.prefs.push(body)
|
||||
// 同服务端:给了的组并进去(mail 的口令不回、只说设没设),交回整份
|
||||
for (const [k, v] of Object.entries(body?.values ?? {})) {
|
||||
state.values[k] = { ...(state.values[k] ?? {}), ...v }
|
||||
if (k === 'mail') {
|
||||
const pw = v.password
|
||||
state.values.mail = { ...state.values.mail, password: null,
|
||||
password_set: typeof pw === 'string' ? pw !== '' : !!state.values.mail.password_set }
|
||||
}
|
||||
}
|
||||
return json(route, state.values)
|
||||
}
|
||||
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, state.values)
|
||||
})
|
||||
await page.route(/\/api\/settings\/mail\/test$/, (route) => {
|
||||
state.mailTests += 1
|
||||
return json(route, { ok: true, message: '已交给邮件服务器(发到 root@example.com),是否送达以邮件服务器为准,请到收件箱确认' })
|
||||
})
|
||||
await page.route(/\/api\/capabilities\/sync$/, (route) => { state.syncs += 1; return json(route, { changed: 2 }) })
|
||||
await page.route(/\/api\/capabilities\/[^/]+\/governance$/, (route) => {
|
||||
@@ -177,10 +202,16 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
await page.route(/\/api\/audit(\?.*)?$/, (route) => json(route, { items: [
|
||||
{ id: 'a1', at: '2026-10-10T02:00:00Z', actor: '张工', verified: false, action: 'capability.govern', subject_kind: 'capability',
|
||||
subject_id: 'erp_pay', data: { version: 2 } }] }))
|
||||
await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [
|
||||
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
|
||||
events: ['task.opened'], enabled: true }] }))
|
||||
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: [] }))
|
||||
await page.route(/\/api\/notify\/channels$/, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
state.channelWrites.push(body)
|
||||
return json(route, { id: 'ch-new', owner_id: null, url_host: '', signed: false, enabled: true, ...body }, 201)
|
||||
}
|
||||
return json(route, { items: notify?.items ?? NOTIFY_ITEMS, ...(notify?.mail ? { mail: notify.mail } : {}) })
|
||||
})
|
||||
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: deliveries }))
|
||||
|
||||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(300)
|
||||
@@ -311,23 +342,107 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总
|
||||
return waitFor(page, async () => await dlg.locator(`[data-notify-events="${value}"]`).count() === 1)
|
||||
}
|
||||
const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown']
|
||||
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval)
|
||||
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval);
|
||||
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道
|
||||
check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
|
||||
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed']),
|
||||
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed', 'key.expiring']),
|
||||
JSON.stringify(await listed()))
|
||||
const text = await dlg.innerText()
|
||||
// 只推定时发起的评测:叫法照实带「定时」,勾上的人不会以为手动发起的那次也推
|
||||
check('评测的两个事件照实说是定时的', ['定时评测有退步', '定时评测没有评完'].every((t) => text.includes(t)), text)
|
||||
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
|
||||
check('登记了还没推送的不列', !['每天的待办汇总', '访问密钥快到期', '数据源有变化要处理', '口径升版,答案的数变了']
|
||||
check('登记了还没推送的不列', !['数据源有变化要处理', '口径升版,答案的数变了']
|
||||
.some((t) => text.includes(t)), text)
|
||||
check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text)
|
||||
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app')
|
||||
&& JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed()))
|
||||
check('个人渠道:五项都列', await scope('user') && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']),
|
||||
check('个人渠道:连同待办汇总、密钥快到期都列', await scope('user')
|
||||
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'task.digest', 'key.expiring']),
|
||||
JSON.stringify(await listed()))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
})
|
||||
|
||||
const MAIL = { enabled: true, host: 'smtp.example.com', port: 587, security: 'starttls', username: 'agentlab', password: null,
|
||||
password_set: true, from_addr: 'agentlab@example.com', from_name: 'AgentLab', default_for_assignees: false }
|
||||
|
||||
await section('设置 → 通知:邮件服务一节、每天的待办汇总', async () => {
|
||||
const { page, state, errors } = await open('/settings/notify', { settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } },
|
||||
notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
|
||||
const box = page.locator('[data-mail-section="edit"]')
|
||||
check('管理员看得到、改得了邮件服务', await waitFor(page, async () => await box.count() === 1)
|
||||
&& await box.locator('[data-mail-field="host"]').inputValue() === 'smtp.example.com')
|
||||
check('口令只说设没设,输入框是空的', await box.locator('[data-mail-field="password"]').inputValue() === ''
|
||||
&& (await box.innerText()).includes('已设置;留空表示不修改'))
|
||||
check('没改动时能发测试邮件', !(await box.locator('[data-mail-test]').isDisabled()))
|
||||
await box.locator('[data-mail-field="host"]').fill('mail.example.org')
|
||||
check('有没保存的改动时先保存再发', await box.locator('[data-mail-test]').isDisabled() && (await box.innerText()).includes('先保存再发测试邮件'))
|
||||
await box.locator('[data-mail-save]').click()
|
||||
check('保存交的是 mail 一组,口令没填就不带', await waitFor(page, async () => state.prefs.length === 1)
|
||||
&& state.prefs[0].values.mail.host === 'mail.example.org' && !('password' in state.prefs[0].values.mail),
|
||||
JSON.stringify(state.prefs[0]))
|
||||
await box.locator('[data-mail-test]').click()
|
||||
check('测试邮件照服务端的话写「已交给邮件服务器」', await waitFor(page, async () => state.mailTests === 1)
|
||||
&& await waitFor(page, async () => (await page.locator('body').innerText()).includes('已交给邮件服务器(发到')))
|
||||
check('写明退信不追踪', (await box.locator('[data-mail-handoff]').innerText()).includes('退信不追踪'))
|
||||
const digest = page.locator('[data-digest-section="edit"]')
|
||||
check('待办汇总的发送时间', await digest.locator('[data-digest-at]').inputValue() === '09:00')
|
||||
await digest.locator('[data-digest-at]').fill('17:30')
|
||||
await digest.locator('[data-digest-save]').click()
|
||||
check('只交汇总时间这一项', await waitFor(page, async () => state.prefs.length === 2)
|
||||
&& JSON.stringify(state.prefs[1]) === JSON.stringify({ values: { notify: { digest_at: '17:30' } } }), JSON.stringify(state.prefs[1]))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
|
||||
const builder = { mode: 'local', username: 'bea', display_name: '搭建者', role: 'builder', groups: [], verified: true }
|
||||
const ro = await open('/settings/notify', { me: builder, settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } } })
|
||||
const roBox = ro.page.locator('[data-mail-section="readonly"]')
|
||||
check('搭建者只看:输入框都不能改、没有保存和测试', await waitFor(ro.page, async () => await roBox.count() === 1)
|
||||
&& await roBox.locator('[data-mail-field="host"]').isDisabled() && await roBox.locator('[data-mail-save]').count() === 0
|
||||
&& await roBox.locator('[data-mail-test]').count() === 0 && (await roBox.innerText()).includes('只有管理员能修改邮件服务'))
|
||||
})
|
||||
|
||||
await section('设置 → 通知:邮件格式、个人邮件渠道发到账号邮箱、投递记录', async () => {
|
||||
const off = await open('/settings/notify', { notify: { items: NOTIFY_ITEMS, mail: { enabled: false, account_email: null } } })
|
||||
await off.page.getByRole('button', { name: '添加渠道' }).first().click()
|
||||
const offDlg = off.page.locator('[role="dialog"]')
|
||||
const options = await offDlg.locator('[data-notify-format-select] option').allInnerTexts()
|
||||
check('邮件服务没启用:不列「邮件」,写明为什么', !options.includes('邮件')
|
||||
&& (await offDlg.locator('[data-notify-mail-off]').innerText()).includes('邮件服务没有启用'), options.join('、'))
|
||||
|
||||
const me = { mode: 'local', username: 'uma', display_name: '小乌', role: 'user', groups: [], verified: true, user_id: 'u-uma' }
|
||||
const items = [
|
||||
{ id: 'm1', name: '我的邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
|
||||
events: ['task.opened'], enabled: true, mail_to: ['uma@example.com'] },
|
||||
{ id: 'm2', name: '旧邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
|
||||
events: ['task.opened'], enabled: true, mail_to: [] },
|
||||
]
|
||||
const deliveries = [
|
||||
{ id: 'd1', channel_id: 'm1', event: 'task.opened', status: 'ok', http_status: null, error: null, format: 'email', created_at: '2026-10-15T02:00:00Z' },
|
||||
]
|
||||
const { page, state, errors } = await open('/settings/notify', { me, deliveries,
|
||||
notify: { items, mail: { enabled: true, account_email: 'uma@example.com' } } })
|
||||
const row = page.locator('[data-notify-channel="我的邮件"]')
|
||||
check('个人邮件渠道写「发到账号邮箱 …」', await waitFor(page, async () => await row.count() === 1)
|
||||
&& (await row.locator('[data-notify-target]').innerText()).includes('发到账号邮箱 uma@example.com'))
|
||||
check('账号没有邮箱的照实写发不出去', (await page.locator('[data-notify-channel="旧邮件"] [data-notify-target]').innerText())
|
||||
.includes('没有邮箱地址'))
|
||||
await page.getByRole('button', { name: '添加渠道' }).first().click()
|
||||
const dlg = page.locator('[role="dialog"]')
|
||||
await dlg.locator('[data-notify-format-select]').selectOption('email')
|
||||
check('个人邮件渠道不填地址,写发到账号邮箱', await dlg.locator('[data-notify-mail-to="account"]').count() === 1
|
||||
&& (await dlg.locator('[data-notify-mail-to="account"]').innerText()).includes('发到账号邮箱 uma@example.com')
|
||||
&& await dlg.locator('input[placeholder="https://"]').count() === 0)
|
||||
await dlg.getByLabel('名称').fill('待办邮件')
|
||||
await dlg.locator('[data-notify-save]').click()
|
||||
check('建的时候不带地址', await waitFor(page, async () => state.channelWrites.length === 1)
|
||||
&& state.channelWrites[0].format === 'email' && state.channelWrites[0].scope === 'user' && !('url' in state.channelWrites[0]),
|
||||
JSON.stringify(state.channelWrites[0]))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
const admin = await open('/settings/notify', { deliveries, notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
|
||||
const d = admin.page.locator('[data-delivery-format="email"]')
|
||||
check('投递记录里邮件成功写「已交给邮件服务器」,不写已送达', await waitFor(admin.page, async () => await d.count() === 1)
|
||||
&& (await d.innerText()).includes('已交给邮件服务器') && !(await d.innerText()).includes('已送达'))
|
||||
})
|
||||
|
||||
await section('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', async () => {
|
||||
const { page, state, errors } = await open('/tools/capabilities?view=review')
|
||||
check('左栏换成待审阅视图', await page.locator('[data-capability-views="review"]').count() === 1)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// 守的是 2026-09 文案整改定下的写法(术语表和语体规范见 frontend/src/lib/terms.ts 开头):
|
||||
// - 「跑」作动词(工作流写「运行」,节点、工具写「执行」)、后端(写「服务端」)、多半、照样、眼下、压根、
|
||||
// 取不到、没取回来、读不懂、连不上、写坏了(写「无法 / 未能 / 失败」)、
|
||||
// 令牌 / tok / tokens(写「token」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、
|
||||
// 令牌 / tok / tokens(写「token」)、快照链接(写「分享链接」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、
|
||||
// 供应商(写「模型接入」)、界面上的 Copilot(写「助手」);
|
||||
// - 句尾语气词(吧、呢、哦……);
|
||||
// - 句子里裸写的配置键名和枚举值(report_from、require_citation、cells: true……),要写界面上的中文标签;
|
||||
@@ -67,6 +67,8 @@ const RULES = [
|
||||
test: (t) => t.match(/.{0,6}(?:多半|照样|眼下|压根|取不到|没取回来|读不懂|连不上|写坏了).{0,6}/)?.[0],
|
||||
},
|
||||
{ id: '令牌', what: '「令牌」:写「token」', test: (t) => t.match(/.{0,6}令牌.{0,6}/)?.[0] },
|
||||
// 总方案 X10:不记名的只读链接叫「分享链接」,「快照」专指查询快照
|
||||
{ id: '快照链接', what: '「快照链接」:写「分享链接」(「快照」专指查询快照)', test: (t) => t.match(/.{0,6}快照链接.{0,6}/)?.[0] },
|
||||
{
|
||||
id: 'tok', what: '「tok」「tokens」:写「token」(如「2.8k token」)',
|
||||
test: (t, prose) => (prose
|
||||
@@ -462,6 +464,8 @@ console.log('\n=== 扫描器自检(拿构造的源码喂一遍)===')
|
||||
&& !strings.some((s) => s.text === '跑') && !seen('./跑.ts') && !strings.some((s) => s.text === '后端'))
|
||||
check('合规的文案不误报', !lint('保存并继续运行', true).length && !lint('56.0k token', false).length
|
||||
&& !lint('已达上限(3 句),这句未裁判', true).length)
|
||||
check('「快照链接」报出来,「分享链接」「查询快照」不误报', lint('复制快照链接', true).some((h) => h.rule === '快照链接')
|
||||
&& !lint('复制分享链接', true).length && !lint('打开查询快照', true).length)
|
||||
|
||||
const py = [
|
||||
'from fastapi import HTTPException',
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// 分享链接的检查(docs/design/next-round/04-对外集成与触达.md C2):/s 分享页和应用编辑里的分享管理。
|
||||
//
|
||||
// 守的是:
|
||||
// - /s 不进外壳:没有导航,不认人、不取目录、不连心跳——整页只发一个请求 GET /api/shares/page;
|
||||
// - 分享 key 从链接的 # 后面读,放进请求头 X-AgentLab-Share,地址里不带;
|
||||
// - 用同一个视图渲染器画:指标照常显示;待办、发起按钮这些服务端换成的说明块照画,没有待办卡、没有发起按钮;
|
||||
// - 印章常显:「数据的来历」里逐个列出视图的印章(页面上没放印章组件也列);
|
||||
// - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;
|
||||
// - 点数字不开在线证据,说明出处在下载的报告里;下载报告带请求头、只取这条分享解析出来的运行;
|
||||
// - 撤销、到期、链接不完整:照服务端的话写明(「链接已撤销」……),不给空白页;
|
||||
// - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」;允许嵌入的照常显示;
|
||||
// - 应用编辑 → 岗位与授权:管理员没开开关时不给新建、写明去哪开;开了之后建分享(页面、数据、有效期、嵌入),链接只显示
|
||||
// 这一次;列表写状态、打开次数;撤销先确认。
|
||||
//
|
||||
// 不写库:/api/shares/*、/api/apps/* 全部伪造。其余写请求一律 409 并记账,检查结束时必须是空的。
|
||||
//
|
||||
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
|
||||
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-share.mjs
|
||||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
const ONLY = (process.env.SHARE_ONLY ?? '').split(',').filter(Boolean)
|
||||
|
||||
let failed = 0
|
||||
const check = (name, cond, detail = '') => {
|
||||
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
|
||||
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
|
||||
if (!cond) failed++
|
||||
}
|
||||
const opened = new Set()
|
||||
async function section(name, fn) {
|
||||
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
|
||||
console.log(`=== ${name} ===`)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||||
} finally {
|
||||
for (const c of opened) await c.close().catch(() => {})
|
||||
opened.clear()
|
||||
}
|
||||
}
|
||||
const waitFor = async (page, fn, ms = 5000) => {
|
||||
for (let t = 0; t < ms; t += 100) {
|
||||
if (await fn().catch(() => false)) return true
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v))
|
||||
const writes = []
|
||||
// 只拦接口:按路径的开头认(/src/api/client.ts 这类前端模块的地址里也有「/api/」,不能按正则拦)
|
||||
const API = (url) => url.pathname.startsWith('/api/')
|
||||
|
||||
// ---------------------------------------------------------------- 夹具(通用示例名)
|
||||
|
||||
const KEY = 'Sh4reKeyExampleOnly_0123456789abcdefGHIJ'
|
||||
const STAMP = {
|
||||
as_of: '2026-10-15T01:00:00Z', as_of_text: '截至 2026-10-15 09:00;统计期 week 2026-W41', pin: '周报 v3', graph_hash: 'ab12cd34',
|
||||
calibers: [{ name: '周报口径', version: 'v3' }], tier: 'formal', tier_text: '完整出具', from: '定时任务「周一出数」第 6 次', sealed: true,
|
||||
}
|
||||
const VIEW = {
|
||||
format: 'agentlab.view/1',
|
||||
app: { id: 'app-share', name: '门店周报', version: 3, level: 'published', needs_upgrade: false },
|
||||
page: { id: 'home', title: '本周概览' },
|
||||
pages: [{ id: 'home', title: '本周概览' }],
|
||||
sources: {
|
||||
wk: { state: 'ok', kind: 'latest_sealed', run_id: 'run-share-1', stamp: STAMP },
|
||||
ask: { state: 'empty', kind: 'on_demand', run_id: null, stamp: null, message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' },
|
||||
},
|
||||
tree: [
|
||||
{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '18,218.54元', name: '销售额', unit: '元',
|
||||
evidence: { run_id: 'run-share-1', kind: 'metric', node: 'card', metric: 'gmv', eid: 'e1' } } },
|
||||
{ id: 'asked', type: 'metric', props: { label: '按条件查看' }, data: { state: 'empty',
|
||||
message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' } },
|
||||
{ id: 'todo', type: 'notice', props: { text: '分享链接不列待办:待办只给派给的人,请打开应用处理', tone: 'info' },
|
||||
share_placeholder: 'task_list' },
|
||||
{ id: 'go', type: 'notice', props: { text: '分享链接是只读的,不能在这里发起', tone: 'info' }, share_placeholder: 'entry_button' },
|
||||
],
|
||||
forms: {}, problems: [],
|
||||
}
|
||||
const PAYLOAD = {
|
||||
format: 'agentlab.share/1',
|
||||
share: { mode: 'latest', created_by: '编辑甲', created_at: '2026-10-14T02:00:00Z', expires_at: '2026-11-13T02:00:00Z', embed_allowed: false },
|
||||
view: VIEW,
|
||||
downloads: [{ view: 'wk', run_id: 'run-share-1', reports: ['write'], as_of_text: STAMP.as_of_text }],
|
||||
}
|
||||
|
||||
async function openShare({ hash = `#${KEY}`, payload = PAYLOAD, status = 200, error = null } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, acceptDownloads: true })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(8000)
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const state = { api: [], pages: [], reports: [] }
|
||||
page.on('request', (req) => { if (new URL(req.url()).pathname.startsWith('/api/')) state.api.push(new URL(req.url()).pathname) })
|
||||
const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
await page.route(API, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'GET') return json(route, { detail: '分享页不该请求这个接口' }, 418)
|
||||
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
|
||||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||||
})
|
||||
await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => {
|
||||
const req = route.request()
|
||||
state.pages.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null })
|
||||
return error ? json(route, error, status) : json(route, payload)
|
||||
})
|
||||
await page.route(/\/api\/shares\/report\.html(\?.*)?$/, (route) => {
|
||||
const req = route.request()
|
||||
state.reports.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null })
|
||||
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><title>报告</title>',
|
||||
headers: { 'Content-Disposition': "attachment; filename*=UTF-8''%E5%91%A8%E6%8A%A5.html" } })
|
||||
})
|
||||
await page.goto(`${WEB}/s${hash}`, { waitUntil: 'networkidle' })
|
||||
return { ctx, page, state, errors }
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
// ---------------------------------------------------------------- 分享页
|
||||
|
||||
await section('分享页:不进外壳,key 只放在请求头里', async () => {
|
||||
const { page, state, errors } = await openShare()
|
||||
check('页面画出来了', await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1))
|
||||
check('没有外壳的导航', await page.locator('nav').count() === 0 && await page.locator('[data-nav-item]').count() === 0)
|
||||
check('只请求了分享页一个接口(不认人、不取目录、不连心跳)',
|
||||
state.api.length >= 1 && state.api.every((p) => p === '/api/shares/page'), state.api.join('、'))
|
||||
const [first] = state.pages
|
||||
check('key 在请求头 X-AgentLab-Share 里', first?.header === KEY)
|
||||
check('请求地址里不带 key', !!first && !first.url.includes(KEY))
|
||||
check('页面上没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('分享页:视图照画,流程组件是说明,印章常显,页脚', async () => {
|
||||
const { page } = await openShare()
|
||||
await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1)
|
||||
check('定时出数的指标照常显示', (await page.locator('[data-node="gmv"]').innerText()).includes('18,218.54'))
|
||||
check('按需视图写明只显示定时出数的', (await page.locator('[data-node="asked"]').innerText()).includes('只显示定时出数的视图'))
|
||||
check('没有待办卡、没有发起按钮', await page.locator('[data-component="task_list"], [data-entry-button], [data-task-card]').count() === 0)
|
||||
check('换成的说明块照画', (await page.locator('[data-node="todo"]').innerText()).includes('不列待办')
|
||||
&& (await page.locator('[data-node="go"]').innerText()).includes('只读'))
|
||||
check('只读的标记', (await page.locator('[data-share-readonly]').innerText()).includes('只读'))
|
||||
check('印章常显:页面上没放印章组件也列出来', await page.locator('[data-share-stamps] [data-stamp]').count() === 1
|
||||
&& (await page.locator('[data-share-stamps]').innerText()).includes('完整出具'))
|
||||
const footer = await page.locator('[data-share-footer-text]').innerText()
|
||||
check('页脚写谁分享、什么时候、什么时候到期', /^只读 · 由 编辑甲 于 .+ 分享 · .+ 到期$/.test(footer.trim()), footer)
|
||||
check('没有刷新、提交这些操作', await page.locator('[data-view-refresh], [data-form-submit]').count() === 0)
|
||||
})
|
||||
|
||||
await section('分享页:点数字说明出处在报告里,下载报告带请求头', async () => {
|
||||
const { page, state } = await openShare()
|
||||
await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1)
|
||||
await page.locator('[data-node="gmv"] [data-evidence="metric"]').click()
|
||||
check('不开在线证据,说明出处在下载的报告里',
|
||||
await waitFor(page, async () => await page.locator('[data-share-evidence-note]').count() === 1)
|
||||
&& (await page.locator('[data-share-evidence-note]').innerText()).includes('下载报告'))
|
||||
check('没有去取证据接口', !state.api.some((p) => p.includes('/evidence')))
|
||||
await page.keyboard.press('Escape')
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 5000 }).catch(() => null),
|
||||
page.locator('[data-share-footer] [data-share-download="run-share-1"]').click(),
|
||||
])
|
||||
check('下载了报告文件,用服务端给的文件名', !!download && download.suggestedFilename() === '周报.html', download?.suggestedFilename())
|
||||
const [req] = state.reports
|
||||
check('下载报告带请求头、点名这次分享的运行', req?.header === KEY && req.url.includes('run=run-share-1')
|
||||
&& req.url.includes('report=write') && !req.url.includes(KEY))
|
||||
})
|
||||
|
||||
await section('分享页:撤销、到期、链接不完整都写明', async () => {
|
||||
for (const [code, title, detail] of [
|
||||
['share_revoked', '链接已撤销', '链接已撤销(2026-10-15 10:00):请向分享的人要新的链接'],
|
||||
['share_expired', '链接已到期', '链接已到期(2026-10-15 10:00):请向分享的人要新的链接'],
|
||||
]) {
|
||||
const { page } = await openShare({ status: 410, error: { detail, code } })
|
||||
const box = page.locator(`[data-share-error="${code}"]`)
|
||||
check(`${title}:标题和服务端的原话`, await waitFor(page, async () => await box.count() === 1)
|
||||
&& (await box.innerText()).includes(title) && (await box.innerText()).includes('请向分享的人要新的链接'))
|
||||
}
|
||||
const { page, state } = await openShare({ hash: '' })
|
||||
check('链接不完整(没有 # 后面那段):写明,不发请求',
|
||||
await waitFor(page, async () => await page.locator('[data-share-error="share_key_missing"]').count() === 1)
|
||||
&& state.pages.length === 0)
|
||||
})
|
||||
|
||||
await section('分享页:没允许嵌入的不在别的页面里显示,允许的照常', async () => {
|
||||
for (const allowed of [false, true]) {
|
||||
const payload = clone(PAYLOAD)
|
||||
payload.share.embed_allowed = allowed
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(payload) }))
|
||||
await page.route((url) => API(url) && !url.pathname.startsWith('/api/shares/'), (route) => route.fulfill({ status: 418, body: '' }))
|
||||
await page.setContent(`<!doctype html><iframe id="f" src="${WEB}/s#${KEY}" style="width:1000px;height:700px"></iframe>`)
|
||||
const frame = await (async () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const f = page.frames().find((x) => x.url().includes('/s'))
|
||||
if (f) return f
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
const want = allowed ? 'ok' : 'framed'
|
||||
const ok = !!frame && await waitFor(page, async () => await frame.locator(`[data-share-page="${want}"]`).count() === 1)
|
||||
check(allowed ? '允许嵌入:iframe 里照常显示' : '没允许嵌入:iframe 里不显示内容,给在新窗口打开', ok)
|
||||
if (!allowed && frame) check('嵌着时没有画视图', await frame.locator('[data-view-renderer]').count() === 0)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 应用编辑里的分享管理
|
||||
|
||||
const SPEC = {
|
||||
format: 'agentlab.app/1', name: '门店周报', description: '', icon: 'app',
|
||||
pins: [{ alias: 'weekly', workflow_id: 'wf-share', version: 3, graph_hash: 'hash3', level: 'published', use: 'view' }],
|
||||
slots: {}, entries: [], forms: {}, task_ui: {},
|
||||
views: { wk: { source: { kind: 'latest_sealed', pin: 'weekly', from: 'formal' } } },
|
||||
pages: [{ id: 'home', title: '本周概览', body: [] }], hooks: [], notify: [],
|
||||
}
|
||||
const shareRow = (over = {}) => ({
|
||||
id: 'sh1', prefix: 'AbC123', app_id: 'app-share', page_id: 'home', page_title: '本周概览', mode: 'latest', app_version: 3,
|
||||
embed_allowed: false, created_by: 'root', created_by_name: '管理员', created_at: '2026-10-14T02:00:00Z',
|
||||
expires_at: '2026-11-13T02:00:00Z', revoked_at: null, revoked_by: null, open_count: 4, last_opened_at: '2026-10-15T02:00:00Z',
|
||||
state: 'active', ...over,
|
||||
})
|
||||
|
||||
async function openEditor({ enabled = true, items = [shareRow()] } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(8000)
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
const state = { items: clone(items), creates: [], revokes: [] }
|
||||
const app = { id: 'app-share', name: SPEC.name, description: '', icon: 'app', status: 'active', published_version: 3,
|
||||
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z',
|
||||
draft: clone(SPEC), draft_bindings: { slots: {} }, created_by: 'root', scope: 'shared',
|
||||
published: { version: 3, pages: [{ id: 'home', title: '本周概览' }], entries: [] } }
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'GET') return route.continue()
|
||||
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
|
||||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||||
})
|
||||
await page.route(/\/api\/apps\/catalog$/, (route) => json(route, { version: 1, components: [] }))
|
||||
await page.route(/\/api\/apps\/app-share(\/.*)?(\?.*)?$/, (route) => {
|
||||
const req = route.request()
|
||||
const parts = new URL(req.url()).pathname.split('/')
|
||||
const sub = parts[4]
|
||||
if (!sub) return json(route, app)
|
||||
if (sub === 'grants') return json(route, { items: [] })
|
||||
if (sub === 'assistant-conversation') return json(route, { id: 'conv-share', kind: 'app', title: '', turns: [] })
|
||||
if (sub === 'shares') {
|
||||
if (req.method() === 'GET') return json(route, { enabled, embed_origins: false, max_days: 90, items: state.items })
|
||||
if (req.method() === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
state.creates.push(body)
|
||||
const row = shareRow({ id: 'sh2', prefix: 'NeW456', mode: body.mode, embed_allowed: body.embed_allowed, open_count: 0,
|
||||
expires_at: body.expires_at })
|
||||
state.items = [row, ...state.items]
|
||||
return json(route, { ...row, key: 'NeW456-secret-share-key', path: '/s#NeW456-secret-share-key', url: null }, 201)
|
||||
}
|
||||
if (req.method() === 'DELETE') {
|
||||
state.revokes.push(parts[5])
|
||||
state.items = state.items.map((r) => (r.id === parts[5] ? { ...r, state: 'revoked', revoked_at: '2026-10-16T02:00:00Z' } : r))
|
||||
return json(route, state.items.find((r) => r.id === parts[5]))
|
||||
}
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`${WEB}/apps/app-share/edit/access`, { waitUntil: 'networkidle' })
|
||||
return { page, state, errors }
|
||||
}
|
||||
|
||||
await section('应用编辑:管理员没开开关时不给新建,写明去哪开', async () => {
|
||||
const { page } = await openEditor({ enabled: false, items: [] })
|
||||
const box = page.locator('[data-app-shares="off"]')
|
||||
check('写明管理员没开、去哪开', await waitFor(page, async () => await box.count() === 1)
|
||||
&& (await box.innerText()).includes('允许建分享链接'))
|
||||
check('没有新建的入口', await page.locator('[data-app-share-create]').count() === 0)
|
||||
})
|
||||
|
||||
await section('应用编辑:建分享链接,链接只显示一次;列表与撤销', async () => {
|
||||
const { page, state, errors } = await openEditor()
|
||||
const row = page.locator('[data-app-share="AbC123"]')
|
||||
check('已有的分享列出状态、打开次数、建的人', await waitFor(page, async () => await row.count() === 1)
|
||||
&& (await row.innerText()).includes('有效') && (await row.innerText()).includes('打开过 4 次')
|
||||
&& (await row.innerText()).includes('由 管理员 建'))
|
||||
await page.locator('[data-app-share-create]').click()
|
||||
check('表单:选页面、数据、有效期、嵌入', await page.locator('[data-app-share-form]').count() === 1
|
||||
&& await page.locator('[data-app-share-days] option').count() === 4)
|
||||
await page.locator('[data-app-share-mode="fixed"]').check()
|
||||
await page.locator('[data-app-share-days]').selectOption('7')
|
||||
await page.locator('[data-app-share-submit]').click()
|
||||
check('建好后链接只显示这一次', await waitFor(page, async () => await page.locator('[data-app-share-once]').count() === 1)
|
||||
&& (await page.locator('[data-app-share-link]').innerText()).endsWith('/s#NeW456-secret-share-key')
|
||||
&& (await page.locator('[data-app-share-once]').innerText()).includes('只显示这一次'))
|
||||
const [body] = state.creates
|
||||
const days = body ? (new Date(body.expires_at).getTime() - Date.now()) / 86_400_000 : 0
|
||||
check('交给服务端的:页面、固定这一期、7 天后到期、不允许嵌入', body?.page_id === 'home' && body.mode === 'fixed'
|
||||
&& days > 6.9 && days < 7.1 && body.embed_allowed === false, JSON.stringify(body))
|
||||
await page.locator('[data-app-share-dismiss]').click()
|
||||
check('关掉之后链接就没有了', await page.locator('[data-app-share-link]').count() === 0
|
||||
&& await page.locator('[data-app-share="NeW456"]').count() === 1)
|
||||
await page.locator('[data-app-share-revoke="AbC123"]').click()
|
||||
const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog'))
|
||||
check('撤销先确认,写明后果', await waitFor(page, async () => (await dialog.innerText()).includes('链接已撤销')))
|
||||
await dialog.getByRole('button', { name: '撤销' }).click()
|
||||
check('撤销了', await waitFor(page, async () => state.revokes.includes('sh1'))
|
||||
&& await waitFor(page, async () => await page.locator('[data-app-share="AbC123"][data-app-share-state="revoked"]').count() === 1))
|
||||
check('页面上没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||||
await browser.close()
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in New Issue
Block a user