"""应用(BE-3):增删改查、派生、检查、发布、版本、授权、页面解析、入口、实例、视图刷新、组件目录。设计文档 §3.10。 应用级权限按 §3.12(app/apps/access.py):use 能用(入口、视图、自己的实例);observe 另能看全部实例;manage 能搭建、 发布、授权、管钩子。关登录时全部放行、等同 manage。 改 spec、改岗位绑定只改草稿,发布才生成不可变的应用版本;改授权、管钩子不发新版本,写审计(A1)。 """ from __future__ import annotations import asyncio import dataclasses import json from typing import Any from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.deps import Principal, actor_of, current_principal from app.apps.access import ROLE_TEXT, app_role, at_least, grants_of from app.db.base import get_session from app.db.models import Run from app.db.models_apps import App, AppGrant, AppHook, AppVersion router = APIRouter(prefix="/api/apps", tags=["apps"]) APP_GONE = "应用不存在,可能已被删除" NO_ACCESS = "app_forbidden" NOT_PUBLISHED = "app_unpublished" PUBLISH_CONFLICT = "app_publish_conflict" #: 归档了的应用不再改草稿、不再发布(没有恢复的接口:归档后这个应用只留着给进行中的实例走完、给人查) APP_ARCHIVED = "app_archived" @router.get("/catalog") async def get_catalog() -> dict: """受信组件目录 v1。前端按名字找渲染器;助手生成时给模型看同一份。""" from app.apps.catalog import catalog_payload return catalog_payload() # -------------------------------------------------------------------------- # 取应用、判权限 # -------------------------------------------------------------------------- def _principal(x_actor: str | None) -> Principal: """这次请求的身份。关登录时 username 换成自报的署名(X-Actor):发起人、我发起的都按它认。""" principal = current_principal() if principal.mode == "off" and principal.username is None: return dataclasses.replace(principal, username=actor_of(x_actor)) return principal async def _app(session: AsyncSession, app_id: str, principal: Principal, need: str) -> tuple[App, str]: app = await session.get(App, app_id) if app is None: raise HTTPException(404, APP_GONE) role = await app_role(session, principal, app) if role is None: # 没有任何授权的人不该知道这个应用在不在 raise HTTPException(404, APP_GONE) if not at_least(role, need): raise CodedHTTPException(403, f"需要这个应用的「{ROLE_TEXT[need]}」权限", NO_ACCESS) return app, role def _refuse_archived(app: App, what: str) -> None: if app.status == "archived": raise CodedHTTPException(409, f"「{app.name}」已归档,不能再{what}", APP_ARCHIVED) #: 个人应用(docs/design/normal-user-apps.md N7)没有的那几样:发布、授权 PERSONAL_ONLY_MINE = "personal.not_shareable" def _refuse_personal(app: App, what: str) -> None: """个人应用没有应用版本(草稿就是在用的那一份)、不能分享:发布、授权一律不收,说清想给别人用怎么办。""" if getattr(app, "scope", "shared") == "personal": raise CodedHTTPException(409, f"「{app.name}」是个人应用,只给你自己用,不能{what}。想给别人用,请提交审核,由搭建者" "发布成正式应用", PERSONAL_ONLY_MINE) def _require_builder(principal: Principal) -> None: if not principal.at_least("builder"): raise CodedHTTPException(403, "搭建应用需要搭建者权限", NO_ACCESS) async def _current_version(session: AsyncSession, app: App) -> AppVersion: if getattr(app, "scope", "shared") == "personal": # 个人应用没有发布版本:不发起实例、没有按需视图,页面直接按存的样子显示,要新数点「刷新」 raise CodedHTTPException(409, f"「{app.name}」是个人应用,不发起实例、没有按需视图:要看最新的数请点「刷新」", NOT_PUBLISHED) if not app.published_version: raise CodedHTTPException(409, f"「{app.name}」还没有发布", NOT_PUBLISHED) return (await session.execute(select(AppVersion).where( AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one() async def _needs_upgrade(session: AsyncSession, version: AppVersion | None) -> bool: from app.apps.pins import load_pins, needs_upgrade if version is None: return False return needs_upgrade(await load_pins(session, version.spec or {})) async def _pending(session: AsyncSession, app_id: str, principal: Principal) -> int: """这个人在这个应用里的待办数。关登录时所有待办都算「我的」。""" from app.db.models_process import Task, TaskAssignment query = select(func.count(Task.id)).where(Task.app_id == app_id, Task.status == "pending") if principal.mode != "off": mine = select(TaskAssignment.task_id).where(TaskAssignment.status == "pending").where( (TaskAssignment.principal_kind == "anyone") | ((TaskAssignment.principal_kind == "user") & (TaskAssignment.principal == (principal.username or ""))) | ((TaskAssignment.principal_kind == "group") & TaskAssignment.principal.in_(list(principal.groups) or [""]))) query = query.where(Task.id.in_(mine)) return int(await session.scalar(query) or 0) async def _summary(session: AsyncSession, app: App, principal: Principal, role: str | None) -> dict[str, Any]: version = None if app.published_version: version = (await session.execute(select(AppVersion).where( AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none() return {"id": app.id, "name": app.name, "description": app.description, "icon": app.icon, "status": app.status, "published_version": app.published_version, "level": version.level if version else None, "needs_upgrade": await _needs_upgrade(session, version), "pending_tasks": await _pending(session, app.id, principal), "my_role": role, "created_at": app.created_at.isoformat() if app.created_at else None, "updated_at": app.updated_at.isoformat() if app.updated_at else None, # 个人应用(N7):scope=personal、拥有者;出自哪个蓝图(共享应用经发布单生成的也有) "scope": app.scope or "shared", "owner": app.owner, "blueprint_id": app.blueprint_id} def _personal_stub(app: App) -> dict[str, Any]: """管理员看到的别人的个人应用(列表、详情、归档的回包同一份):名称、拥有者、状态、创建和更新时间,能归档;说明、 页面、数、运行、蓝图一概不给(P4,N7「管理员看得到谁有几个个人应用,可以归档,但不看内容」)。""" return {"id": app.id, "name": app.name, "description": "", "icon": "app", "status": app.status, "published_version": None, "level": None, "needs_upgrade": False, "pending_tasks": 0, "my_role": None, "created_at": app.created_at.isoformat() if app.created_at else None, "updated_at": app.updated_at.isoformat() if app.updated_at else None, "scope": "personal", "owner": app.owner, "blueprint_id": None} def _sees_stub(principal: Principal, app: App) -> bool: """这个人对这个应用只看得到元数据(_personal_stub):开了登录的管理员看别人的个人应用。""" from app.apps.access import is_personal return is_personal(app) and principal.mode != "off" and principal.role == "admin" def _published_outline(version: AppVersion | None) -> dict[str, Any] | None: """当前发布版里使用面要的那一点:有哪些页面、哪些入口(名字和标题)。页面内容走页面解析,表单走页面的 forms。""" if version is None: return None spec = version.spec or {} return {"version": version.version, "pages": [{"id": p.get("id"), "title": p.get("title") or ""} for p in spec.get("pages") or [] if isinstance(p, dict) and p.get("id")], "entries": [{"id": e.get("id"), "label": e.get("label") or ""} for e in spec.get("entries") or [] if isinstance(e, dict) and e.get("id")]} def _draft_changed(app: App, previous: AppVersion | None) -> bool | None: """草稿和当前发布版是否一致(N6,页面上直接改时说「有未发布的改动」还是「和发布版相同」)。按发布时的规整方式比 (publish_draft:parse_spec 规整成同一个形状),两边都过一遍,老版本发布时还没有的字段按缺省补齐、不算改动;钉住项的 级别发布时按库里的那一版补、不出自草稿,不比。岗位绑定一并比。没发布过、发布版规整不了的为 None(不知道,界面不猜); 草稿规整不了的算改过(发布前得先改好)。""" from pydantic import ValidationError from app.apps.lint import pins_with_state from app.apps.spec import parse_spec from app.core.artifact_store import canonical_json def normal(spec: Any) -> str: out = parse_spec(spec or {}).model_dump(mode="json", by_alias=True) out["pins"] = pins_with_state(out, {}) return canonical_json(out) if previous is None: return None try: published = normal(previous.spec) except ValidationError: return None try: draft = normal(app.draft) except ValidationError: return True bindings = {"slots": {}, **(app.draft_bindings or {})} return draft != published or canonical_json(bindings) != canonical_json(previous.bindings or {"slots": {}}) async def _detail(session: AsyncSession, app: App, principal: Principal, role: str | None, *, with_backfill: bool = False) -> dict[str, Any]: """应用详情。草稿和草稿里的岗位绑定只给 manage(D-m):草稿是还没发布、没过检查的东西,岗位绑定是这个部署里具体 的人和组;use / observe 只拿概况和当前发布版的页面、入口目录(published)。 with_backfill:另给管理者列出能按新口径补算的视图、各会发起几次(D-f)。要逐期查封存,只在打开详情时算;保存草稿、 归档这些改动的回包里不算(编辑器存一次草稿就算一遍太浪费,补算只看发布版,草稿改了也不变)。""" from app.apps.spec import empty_spec previous = await _previous(session, app) out = {**await _summary(session, app, principal, role), "published": _published_outline(previous)} if not at_least(role, "manage"): return out out.update(draft=app.draft or empty_spec(app.name), draft_bindings={"slots": {}, **(app.draft_bindings or {})}, created_by=app.created_by) if app.scope == "personal": # 个人应用给拥有者的那一块:今天还能刷新几次、蓝图改过没有、审核到哪了(blueprints/personal.personal_state) from app.blueprints.personal import personal_state out["personal"] = await personal_state(session, app, principal) return out if (changed := _draft_changed(app, previous)) is not None: out["draft_changed"] = changed if with_backfill: from app.apps.backfill import backfillable out["backfill"] = await backfillable(session, app) return out # -------------------------------------------------------------------------- # 增删改查 # -------------------------------------------------------------------------- @router.get("") async def list_apps(include_archived: bool = False, scope: str | None = None, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """应用列表。个人应用只列给拥有者;管理员另外看得到别人的个人应用在不在(_personal_stub,不含内容),能归档。 scope 给了只列那一种(shared / personal)。can_create 是这个人能建哪种应用:新建应用的入口按它给(共享要搭建者及以上, 个人看管理员的开关,blueprints/personal.allowed)。""" from sqlalchemy import or_ from app.blueprints.personal import allowed principal = _principal(x_actor) query = select(App).order_by(App.updated_at.desc()) if not include_archived: query = query.where(App.status != "archived") if scope == "personal": query = query.where(App.scope == "personal") elif scope == "shared": query = query.where(or_(App.scope == "shared", App.scope.is_(None))) items = [] for app in (await session.execute(query)).scalars(): role = await app_role(session, principal, app) if role is None: if _sees_stub(principal, app): items.append(_personal_stub(app)) continue items.append(await _summary(session, app, principal, role)) return {"items": items, "can_create": {"shared": principal.mode == "off" or principal.at_least("builder"), "personal": await allowed(session, principal)}} class AppIn(BaseModel): name: str = Field(min_length=1, max_length=200) description: str = "" icon: str = Field(default="app", max_length=40) spec: dict[str, Any] | None = None @router.post("", status_code=201) async def create_app(payload: AppIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: principal = _principal(x_actor) _require_builder(principal) app = await new_app(session, name=payload.name, description=payload.description, icon=payload.icon, spec=payload.spec, actor=actor_of(x_actor)) return await _detail(session, app, principal, "manage") async def new_app(session: AsyncSession, *, name: str, description: str = "", icon: str = "app", spec: dict[str, Any] | None = None, bindings: dict[str, Any] | None = None, actor: str | None, blueprint_id: str | None = None) -> App: """建一个应用(只有草稿,提交)。建应用的接口和发布单共用;发布单另带上岗位绑定和出自哪个蓝图。""" from app.apps.spec import empty_spec draft = dict(spec) if spec else empty_spec(name) draft["name"] = name app = App(name=name, description=description, icon=icon, draft=draft, draft_bindings={"slots": {}, **(bindings or {})}, created_by=actor, updated_by=actor, blueprint_id=blueprint_id) session.add(app) await session.commit() await session.refresh(app) return app class DeriveIn(BaseModel): workflow_id: str version: int name: str | None = None @router.post("/derive") async def derive(payload: DeriveIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """从工作流版本确定性派生一份应用草稿(不落库)。岗位留空,等人绑定。""" from app.apps.derive import DeriveRefused, derive_app _require_builder(_principal(x_actor)) try: return {"spec": await derive_app(session, payload.workflow_id, payload.version, name=payload.name)} except DeriveRefused as e: raise HTTPException(e.status_code, e.message) from e @router.get("/{app_id}") async def get_app(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """应用详情(_detail)。管理员打开别人的个人应用只拿到元数据(_personal_stub,同列表里那一条),页面、草稿、刷新、 蓝图的接口照旧当它不存在。""" principal = _principal(x_actor) stub = await session.get(App, app_id) if stub is not None and _sees_stub(principal, stub) and await app_role(session, principal, stub) is None: return {**_personal_stub(stub), "published": None} app, role = await _app(session, app_id, principal, "use") return await _detail(session, app, principal, role, with_backfill=True) class AppPatch(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=200) description: str | None = None icon: str | None = Field(default=None, max_length=40) draft: dict[str, Any] | None = None draft_bindings: dict[str, Any] | None = None def _bindings_problem(bindings: dict[str, Any]) -> str | None: """岗位绑定的形状:{"slots": {岗位: [{kind: user|group, name}]}, "hooks"?, "channels"?}。""" slots = bindings.get("slots", {}) if not isinstance(slots, dict): return "岗位绑定需要按岗位列出人或组" for slot, items in slots.items(): if not isinstance(items, list) or not all( isinstance(i, dict) and i.get("kind") in ("user", "group") and isinstance(i.get("name"), str) and i["name"].strip() for i in items): return f"岗位「{slot}」的绑定需要是人或组的列表" return None @router.patch("/{app_id}") async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """改草稿:spec、岗位绑定、名称等。草稿随便改,发布时才过检查(形状不对的检查里报 app.format)。""" principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "manage") _refuse_archived(app, "修改") if payload.draft_bindings is not None and (problem := _bindings_problem(payload.draft_bindings)): raise HTTPException(422, problem) if app.scope == "personal" and payload.draft is not None: # 个人应用的草稿就是在用的那一份:只许改呈现,固定的蓝图、视图来源只由「存成我的应用」改 from app.blueprints.personal import draft_problem if problem := draft_problem(app.draft or {}, payload.draft): raise HTTPException(422, problem) for key in ("name", "description", "icon"): if (value := getattr(payload, key)) is not None: setattr(app, key, value) if payload.draft is not None: app.draft = payload.draft if payload.draft_bindings is not None: app.draft_bindings = payload.draft_bindings app.updated_by = actor_of(x_actor) await session.commit() await session.refresh(app) return await _detail(session, app, principal, role) @router.post("/{app_id}/archive") async def archive_app(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """归档:不再出现在应用列表里,入口拒绝发起、入站钩子一律拒收(apps/entries.py、process/hooks.py),不再改草稿、 不再发布;进行中的实例照常走完,版本、实例、生效记录都留着。没有恢复的接口。""" from app.apps.access import can_archive from app.apps.audit import record principal = _principal(x_actor) app = await session.get(App, app_id) role = await app_role(session, principal, app) if app is not None else None if app is None or not can_archive(principal, app, role): # 管理员能归档别人的个人应用(看不到内容,能收起来);其余照旧要管理权限 app, role = await _app(session, app_id, principal, "manage") app.status = "archived" app.updated_by = actor_of(x_actor) await record(session, action="app.archive", subject_kind="app", subject_id=app.id, actor=actor_of(x_actor), data={"name": app.name, **({"owner": app.owner} if app.scope == "personal" else {})}) await session.commit() await session.refresh(app) if role is None: return {**_personal_stub(app), "published": None} return await _detail(session, app, principal, role) #: (应用 id, 人) → 取或建助手对话的锁。只增不减:编辑器打开过的应用 × 人 _ASSISTANT_LOCKS: dict[tuple[str, str | None], asyncio.Lock] = {} def _assistant_lock(app_id: str, who: str | None) -> asyncio.Lock: return _ASSISTANT_LOCKS.setdefault((app_id, who), asyncio.Lock()) @router.get("/{app_id}/assistant-conversation") async def assistant_conversation(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """应用编辑器里助手的那段对话:没有就建(D-e)。kind=app、归这个应用、建立人是现在这个人——对话按人隔离,同一个应用 几个人轮流改,各自的历史不串。编辑器每轮落一条 turn(问题、说明、meta.looks),润色接口按 conversation_id 带历史。""" from app.api.conversations import _detail from app.db.models import Conversation, ConversationTurn app, _ = await _app(session, app_id, _principal(x_actor), "manage") who = actor_of(x_actor) mine = Conversation.created_by == who if who else Conversation.created_by.is_(None) lookup = (select(Conversation).where(Conversation.kind == "app", Conversation.app_id == app.id, Conversation.archived.is_(False), mine) .order_by(Conversation.last_active_at.desc()).limit(1)) # 取或建在进程里按 (应用, 人) 串行:编辑器开发模式下 effect 执行两次、同一个人开两个标签页,都会同时来取。库里有 # (app_id, created_by) 的部分唯一索引(ux_conversations_app_owner):别处(另一个进程)抢先建了的,这次插入在保存点里 # 被拒,改用那一段——历史才不会被拆开。没署名(关登录又没填)的不受索引约束,靠这把进程内的锁 async with _assistant_lock(app.id, who): row = await session.scalar(lookup) if row is None: fresh = Conversation(kind="app", app_id=app.id, created_by=who, title=f"「{app.name}」的助手") try: async with session.begin_nested(): session.add(fresh) await session.flush() row = fresh except IntegrityError: row = await session.scalar(lookup) if row is None: raise await session.commit() await session.refresh(row) turns = list((await session.execute(select(ConversationTurn).where( ConversationTurn.conversation_id == row.id).order_by(ConversationTurn.seq))).scalars()) # 形状同 GET /api/conversations/{id}(ConversationDetail):编辑器拿它渲染历史、接着落轮次 return {**_detail(row, turns).model_dump(mode="json"), "turn_count": len(turns), "last_question": turns[-1].question if turns else ""} # -------------------------------------------------------------------------- # 检查与发布 # -------------------------------------------------------------------------- async def _previous(session: AsyncSession, app: App) -> AppVersion | None: if not app.published_version: return None return (await session.execute(select(AppVersion).where( AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none() @router.post("/{app_id}/lint") async def lint(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: from app.apps.lint import acl_issues, lint_app from app.apps.pins import load_pins from app.apps.tree import app_level principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "manage") scope = app.scope or "shared" issues = await lint_app(session, app.draft or {}, app.draft_bindings or {}, previous=await _previous(session, app), scope=scope) if scope != "personal": # 个人应用不能授权给别人,「还没有授权」不是问题 issues += await acl_issues(session, app.id) pins = await load_pins(session, app.draft or {}) return {"issues": issues, "level": app_level([{"level": p.level} for p in pins.values()]) if pins else None} class PublishIn(BaseModel): note: str = Field(default="", max_length=2000) dispositions: dict[str, str] = Field(default_factory=dict) @router.post("/{app_id}/publish") async def publish(app_id: str, payload: PublishIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """把草稿冻结成一个不可变的应用版本。钉住项相对上一版升了版而没选处置的不发布(ok=false,needs_disposition 列出)。 返回 200 + ok/issues 而不是抛错,前端把问题列表渲染出来(同工作流发布)。""" principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "manage") _refuse_archived(app, "发布") _refuse_personal(app, "发布") return await publish_draft(session, app, note=payload.note, dispositions=payload.dispositions, actor=actor_of(x_actor)) async def publish_draft(session: AsyncSession, app: App, *, note: str, dispositions: dict[str, str], actor: str | None) -> dict[str, Any]: """检查草稿,过了就冻结成新的应用版本并提交;没过交回 ok=false 和问题(不写库)。发布接口和发布单共用。 两个人同时发布时后到的那个抛 409(app_publish_conflict)。""" from app.apps.audit import record from app.apps.lint import acl_issues, lint_app, needs_disposition, pins_with_state from app.apps.pins import load_pins from app.apps.resolve import clear_cache from app.apps.spec import parse_spec from app.apps.tree import app_level, spec_hash from app.db.base import utcnow previous = await _previous(session, app) draft = app.draft or {} issues = await lint_app(session, draft, app.draft_bindings or {}, previous=previous, dispositions=dispositions) issues += await acl_issues(session, app.id) upgraded = needs_disposition(previous, draft.get("pins") or []) if isinstance(draft.get("pins"), list) else [] if any(i["level"] == "error" for i in issues): return {"ok": False, "issues": issues, "needs_disposition": [u for u in upgraded if dispositions.get(u["alias"]) is None]} pins = await load_pins(session, draft) spec = parse_spec(draft).model_dump(mode="json", by_alias=True) spec["pins"] = pins_with_state(spec, pins) number = int(await session.scalar(select(func.max(AppVersion.version)).where(AppVersion.app_id == app.id)) or 0) + 1 level = app_level(spec["pins"]) row = AppVersion(app_id=app.id, version=number, spec=spec, spec_hash=spec_hash(spec), pins=spec["pins"], bindings={"slots": {}, **(app.draft_bindings or {})}, level=level, dispositions={u["alias"]: dispositions[u["alias"]] for u in upgraded}, note=note, published_by=actor, published_at=utcnow()) session.add(row) app.published_version = number app.updated_by = actor await record(session, action="app.publish", subject_kind="app", subject_id=app.id, actor=actor, data={"version": number, "spec_hash": row.spec_hash, "level": level, "pins": spec["pins"], "dispositions": row.dispositions, "note": note}) try: await session.commit() except IntegrityError as e: # 两个人同时发布:(app_id, version) 唯一,后到的那个撞上,不另加锁 await session.rollback() raise CodedHTTPException(409, "有人刚刚发布了这个应用,请刷新后再试", PUBLISH_CONFLICT) from e clear_cache() return {"ok": True, "version": number, "level": level, "spec_hash": row.spec_hash, "issues": issues} def _version_row(row: AppVersion) -> dict[str, Any]: return {"version": row.version, "spec_hash": row.spec_hash, "level": row.level, "pins": row.pins, "dispositions": row.dispositions or {}, "note": row.note, "published_by": row.published_by, "published_at": row.published_at.isoformat() if row.published_at else None} @router.get("/{app_id}/versions") async def list_versions(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: app, _ = await _app(session, app_id, _principal(x_actor), "observe") rows = (await session.execute(select(AppVersion).where(AppVersion.app_id == app.id) .order_by(AppVersion.version.desc()))).scalars() return {"items": [_version_row(r) for r in rows]} @router.get("/{app_id}/versions/{version}") async def get_version(app_id: str, version: int, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: app, role = await _app(session, app_id, _principal(x_actor), "observe") row = (await session.execute(select(AppVersion).where( AppVersion.app_id == app.id, AppVersion.version == version))).scalar_one_or_none() if row is None: raise HTTPException(404, f"「{app.name}」没有 v{version}") # 岗位绑定是这一个部署里的人和组:管理者才看得到 return {**_version_row(row), "spec": row.spec, "bindings": row.bindings if role == "manage" else {"slots": {}}} # -------------------------------------------------------------------------- # 授权 # -------------------------------------------------------------------------- class GrantItem(BaseModel): principal_kind: str principal: str = "" role: str = "use" class GrantsIn(BaseModel): items: list[GrantItem] def _grant_out(g: AppGrant) -> dict[str, Any]: return {"principal_kind": g.principal_kind, "principal": g.principal, "role": g.role} @router.get("/{app_id}/grants") async def get_grants(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: app, _ = await _app(session, app_id, _principal(x_actor), "manage") return {"items": [_grant_out(g) for g in await grants_of(session, app.id)]} @router.put("/{app_id}/grants") async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """整份替换授权。授权不进应用版本(改了不用重新发布),写审计。 给的人和组按名录核对(auth/directory.py,同选人控件、同应用检查):账号密码登录时名录里没有的、任何登录方式下停用了 的拒收,说清是哪一档、哪个名字——界面上选人时已经拦着,这里拦旧客户端和直接调接口的。单点登录的人第一次登录后才进 名录,名录里还没有的照收(检查里提醒);关登录不核对。""" app, _ = await _app(session, app_id, _principal(x_actor), "manage") _refuse_personal(app, "授权给别人") wanted, problem = await grants_problem(session, [(i.principal_kind, i.principal, i.role) for i in payload.items]) if problem: raise HTTPException(422, problem) return {"items": await replace_grants(session, app, wanted, actor=actor_of(x_actor))} async def grants_problem(session: AsyncSession, items: list[tuple[str, str, str]] ) -> tuple[list[tuple[str, str, str]], str | None]: """要给的授权 [(principal_kind, principal, role)] → (去重规范化之后的, 收不下的说明),只读。形状不对、名录核对不过 (同上面的规则)时说明不为空。授权接口和发布单(预检只检查、最后一步照它授权)共用。""" from app.api.deps import auth_mode from app.auth.directory import load_directory, mismatch from app.db.models_apps import GRANT_ROLES wanted: list[tuple[str, str, str]] = [] for kind, name, role in items: if kind not in ("user", "group", "everyone") or role not in GRANT_ROLES: return wanted, "授权只能给人、组或所有人,权限只能是使用、查看全部实例或管理" principal = "" if kind == "everyone" else (name or "").strip() if kind != "everyone" and not principal: return wanted, "授权给人或组时需要写明是谁" key = (kind, principal, role) if key not in wanted: wanted.append(key) if (mode := auth_mode()) != "off": known = await load_directory(session) refused = [f"「{ROLE_TEXT[role]}」里的{m.text}" for kind, who, role in wanted if (m := mismatch(known, mode, kind, who)) is not None and m.blocking] if refused: return wanted, f"{';'.join(refused)}:授权只能给名录里在用的人和组,请移除或从候选中重新选择" return wanted, None async def replace_grants(session: AsyncSession, app: App, wanted: list[tuple[str, str, str]], *, actor: str | None) -> list[dict[str, str]]: """整份替换授权(调用方先过了 grants_problem),写审计并提交。""" from app.apps.audit import record before = [_grant_out(g) for g in await grants_of(session, app.id)] for g in await grants_of(session, app.id): await session.delete(g) await session.flush() for kind, who, role in wanted: session.add(AppGrant(app_id=app.id, principal_kind=kind, principal=who, role=role, granted_by=actor)) after = [{"principal_kind": k, "principal": p, "role": r} for k, p, r in wanted] await record(session, action="app.grants", subject_kind="app", subject_id=app.id, actor=actor, data={"before": before, "after": after}) await session.commit() return after # -------------------------------------------------------------------------- # 入站钩子的管理(收请求在 /api/hooks/{hook_id},process/hooks.py) # # 按名字认(D-g):spec.hooks 声明 {name, kind, entry},app_hooks 按 (应用, 名字) 一行,只存密钥和启用与否。钩子做 # 什么以当前发布版的声明为准:改草稿不改正在用的钩子,发布版里没声明的拒收。密钥只在生成、轮换时交回一次 # -------------------------------------------------------------------------- class HookPatch(BaseModel): enabled: bool async def _hook_state(session: AsyncSession, app: App) -> tuple[dict[str, Any], dict[str, Any], dict[str, AppHook]]: """(草稿里的声明, 当前发布版里的声明, 已有的行),都按名字。""" from app.process.hooks import declarations version = await _previous(session, app) rows: dict[str, AppHook] = {} for row in (await session.execute(select(AppHook).where(AppHook.app_id == app.id) .order_by(AppHook.created_at))).scalars(): rows.setdefault(row.name, row) return declarations(app.draft or {}), declarations(version.spec if version is not None else {}), rows def _hook_item(name: str, draft: dict[str, Any], published: dict[str, Any], rows: dict[str, AppHook], secret: str | None = None) -> dict[str, Any]: from app.process.hooks import hook_out return hook_out(rows.get(name), name=name, declared=draft.get(name) or published.get(name), in_draft=name in draft, in_published=name in published, secret=secret) @router.get("/{app_id}/hooks") async def list_hooks(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """草稿里声明的 ∪ 已经有行的:每个名字一行,标出密钥生成过没有、当前发布版里有没有(没有的收不到请求)。""" app, _ = await _app(session, app_id, _principal(x_actor), "manage") draft, published, rows = await _hook_state(session, app) names = list(dict.fromkeys([*draft, *published, *rows])) return {"items": [_hook_item(n, draft, published, rows) for n in names]} @router.post("/{app_id}/hooks/{name}/rotate") async def rotate_hook(app_id: str, name: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """生成或轮换这个名字的签名密钥,密钥只在这里交回一次(库里存密文)。名字要在草稿或当前发布版里声明过。""" from app.apps.audit import record from app.process import hooks app, _ = await _app(session, app_id, _principal(x_actor), "manage") draft, published, rows = await _hook_state(session, app) declared = draft.get(name) or published.get(name) if declared is None: raise HTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它") # 从读行锁到提交:两位管理者同时点「生成密钥」,后到的那次读得到前一次插入的行,只轮换它(见 process/hooks.py) async with hooks.hook_lock(app.id, name): row, secret, created = await hooks.rotate_hook(session, app.id, name, kind=declared["kind"], entry=declared.get("entry")) await record(session, action="app.hook_create" if created else "app.hook_rotate", subject_kind="app_hook", subject_id=row.id, actor=actor_of(x_actor), data={"app_id": app.id, "name": name, "kind": declared["kind"], "entry": declared.get("entry")}) await session.commit() await session.refresh(row) return _hook_item(name, draft, published, {**rows, name: row}, secret) async def _hook_named(session: AsyncSession, app: App, name: str) -> AppHook: from app.process.hooks import hook_row row = await hook_row(session, app.id, name) if row is None: raise HTTPException(404, f"钩子「{name}」还没有生成密钥") return row @router.patch("/{app_id}/hooks/{name}") async def update_hook(app_id: str, name: str, payload: HookPatch, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """启用、停用。停用的钩子收到请求一律拒收,密钥留着,重新启用就能接着用。""" from app.apps.audit import record from app.process.hooks import set_enabled app, _ = await _app(session, app_id, _principal(x_actor), "manage") row = await _hook_named(session, app, name) await set_enabled(session, app.id, name, payload.enabled) await record(session, action="app.hook_update", subject_kind="app_hook", subject_id=row.id, actor=actor_of(x_actor), data={"app_id": app.id, "name": name, "enabled": payload.enabled}) await session.commit() draft, published, rows = await _hook_state(session, app) return _hook_item(name, draft, published, rows) @router.delete("/{app_id}/hooks/{name}", status_code=204) async def delete_hook(app_id: str, name: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> None: """删掉这个名字的密钥和去重记录。声明留在草稿里(要去掉在编辑器里删);再生成就是一把新密钥、一个新地址。""" from app.apps.audit import record from app.process import hooks app, _ = await _app(session, app_id, _principal(x_actor), "manage") row = await _hook_named(session, app, name) await record(session, action="app.hook_delete", subject_kind="app_hook", subject_id=row.id, actor=actor_of(x_actor), data={"app_id": app.id, "name": name}) await hooks.delete_hook(session, app.id, name) await session.commit() # -------------------------------------------------------------------------- # 使用:页面、入口、实例、视图刷新 # -------------------------------------------------------------------------- def _params(raw: str | None) -> dict[str, Any]: if not raw: return {} try: value = json.loads(raw) except ValueError as e: raise HTTPException(422, "页面参数需要是 JSON 对象") from e if not isinstance(value, dict): raise HTTPException(422, "页面参数需要是 JSON 对象") return value async def _instance(session: AsyncSession, app: App, run_id: str, principal: Principal, role: str) -> Run: """实例要属于这个应用;只有 use 的人只能看自己发起的(关登录时都能看)。""" run = await session.get(Run, run_id) if run is None or run.app_id != app.id or run.origin_kind not in ("app_entry", "hook"): raise HTTPException(404, "实例不存在") if principal.mode != "off" and not at_least(role, "observe") and run.started_by != principal.username: raise CodedHTTPException(403, "只能查看自己发起的实例", NO_ACCESS) return run def _view_refused(e: Any) -> HTTPException: return CodedHTTPException(e.status_code, e.message, e.code) if e.code else HTTPException(e.status_code, e.message) @router.get("/{app_id}/pages/{page_id}") async def get_page(app_id: str, page_id: str, instance: str | None = None, params: str | None = None, draft: int = Query(default=0), session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """页面解析:服务端解析好的视图模型(agentlab.view/1)。draft=1 按草稿解析(编辑器预览,要管理权限),不发起运行。""" from app.apps.resolve import ViewRefused, resolve_page principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "manage" if draft else "use") # 个人应用没有应用版本:草稿就是在用的那一份(resolve_page 按 scope 认) version = None if draft or app.scope == "personal" else await _current_version(session, app) run = await _instance(session, app, instance, principal, role) if instance else None try: return await resolve_page(session, app, page_id=page_id, principal=principal, role=role, instance=run, params=_params(params), draft=bool(draft), version=version, actor=actor_of(x_actor)) except ViewRefused as e: raise _view_refused(e) from e class StartIn(BaseModel): values: dict[str, Any] = Field(default_factory=dict) @router.post("/{app_id}/entries/{entry_id}", status_code=201) async def start(app_id: str, entry_id: str, payload: StartIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> Any: """发起一个实例。表单没通过校验回 422,带 {errors: [{path, code, message}]}。""" from app.apps.entries import EntryRefused, FormRejected, start_entry from app.apps.resolve import instance_title from app.engine.launch import LaunchRefused principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "use") try: run = await start_entry(app.id, entry_id, payload.values, principal) except FormRejected as e: return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "errors": e.errors}) except (EntryRefused, LaunchRefused) as e: raise _view_refused(e) from e return {"run_id": run.id, "title": instance_title(run)} @router.get("/{app_id}/instances") async def list_instances(app_id: str, who: str = "mine", entry: str | None = None, status: str | None = None, limit: int = Query(default=50, ge=1, le=200), session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """实例列表。看全部要 observe;只有 use 的只看自己发起的。""" from app.apps.resolve import list_instances as instances principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "use") if who == "all" and not at_least(role, "observe"): raise CodedHTTPException(403, "查看全部实例需要这个应用的「查看全部实例」权限", NO_ACCESS) mine = principal.username if who != "all" else None return {"items": await instances(session, app.id, mine=mine, entry=entry, status=status, limit=limit)} @router.get("/{app_id}/instances/{run_id}") async def get_instance(app_id: str, run_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """一个实例:列表里的那一行,加上实例页的视图模型(应用里叫 instance 的页;没有就给一页默认的)。""" from app.apps.resolve import ViewRefused, instance_page, instance_rows, resolve_page principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "use") run = await _instance(session, app, run_id, principal, role) version = (await session.execute(select(AppVersion).where( AppVersion.app_id == app.id, AppVersion.version == (run.app_version or app.published_version))))\ .scalar_one_or_none() or await _current_version(session, app) spec = version.spec or {} page_id = instance_page(spec) if page_id is None: # 应用里没有实例页:给一页默认的(概况、进度、待办),不改应用版本本身 spec = {**spec, "pages": [*(spec.get("pages") or []), {"id": "__instance", "title": "实例", "body": [ {"id": "__header", "type": "instance_header"}, {"id": "__timeline", "type": "instance_timeline"}, {"id": "__tasks", "type": "task_list", "props": {"scope": "instance"}}]}]} page_id = "__instance" version = AppVersion(app_id=version.app_id, version=version.version, spec=spec, spec_hash=version.spec_hash, pins=version.pins, bindings=version.bindings, level=version.level, dispositions=version.dispositions) try: view = await resolve_page(session, app, page_id=page_id, principal=principal, role=role, instance=run, version=version, actor=actor_of(x_actor)) except ViewRefused as e: raise _view_refused(e) from e return {**(await instance_rows(session, [run]))[0], "view": view} class RefreshIn(BaseModel): params: dict[str, Any] = Field(default_factory=dict) @router.post("/{app_id}/views/{view_id}/refresh") async def refresh_view(app_id: str, view_id: str, payload: RefreshIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> Any: """按需视图:发起一次运行,或者命中缓存、接上同一个参数正在运行的那次。""" from app.apps.entries import FormRejected from app.apps.resolve import ViewRefused, run_view from app.engine.launch import LaunchRefused principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "use") version = await _current_version(session, app) try: run, cached = await run_view(session, app=app, version=version, view_id=view_id, params=payload.params, principal=principal, actor=actor_of(x_actor)) except FormRejected as e: return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "errors": e.errors}) except (ViewRefused, LaunchRefused) as e: raise _view_refused(e) from e return {"run_id": run.id, "cached": cached} # -------------------------------------------------------------------------- # 按新口径补算(D-f) # -------------------------------------------------------------------------- @router.get("/{app_id}/views/{view_id}/backfill") async def backfill_plan(app_id: str, view_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """补算会发起几次、跳过几次(不发起运行):按钮上写明会发起几次运行用。""" from app.apps.backfill import BackfillRefused, plan app, _ = await _app(session, app_id, _principal(x_actor), "manage") version = await _current_version(session, app) try: return (await plan(session, app, version, view_id)).out() except BackfillRefused as e: raise _view_refused(e) from e @router.post("/{app_id}/views/{view_id}/backfill") async def backfill(app_id: str, view_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """按新口径补算:这个视图的钉住项最近一次升版选了 recompute 时,取旧版本在同一来源上最近几期的输入,用新版本逐个 发起正式运行(来源 app_backfill);新版本已经算过同一个输入的跳过。返回发起了几次、跳过几次。""" from app.apps.backfill import BackfillRefused, run_backfill from app.apps.resolve import clear_cache from app.engine.launch import LaunchRefused principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "manage") version = await _current_version(session, app) try: out = await run_backfill(session, app, version, view_id, principal=principal, actor=actor_of(x_actor)) except BackfillRefused as e: raise _view_refused(e) from e except LaunchRefused as e: raise _view_refused(e) from e clear_cache() return out