主题
项目管理与资源授权
本章目标:
- 看懂
Project / ProjectMember / ProjectNcMount / ProjectAgentMount四张表如何组织"项目"这一业务核心,以及 create/update/delete 的事务语义- 掌握三级授权依赖(成员 / owner / admin)的判权逻辑、错误码语义与紧急降级开关
- 理解"资源级链式反查"——拿
calendar_id/meeting_id/file_id怎么反查到所属 project 再判权,以及 admin 跨用户访问为何要独立 API
TL;DR
项目是 a-cdm 业务域的根聚合:Project 下挂 Calendar→Meeting→MeetingFile 内容树、ProjectMember 成员表、ProjectNcMount 文档挂载、ProjectAgentMount AI 同事挂载。授权是两段式:get_current_user(util/auth.py:37)先认证出 CurrentUser,再由三个项目级依赖 require_project_access(成员)/ require_project_owner(owner)/ require_admin 按需判权(auth/dependencies.py)。子资源(calendar/meeting/file/report/mount)没有自己的成员表,而是经 resource_deps.py 的链式反查(file→meeting→calendar→project_id)复用项目成员判定。所有判权可经 ACDM_PROJECT_ACCESS_ENABLED=false 一键降级回"只查存在性"。admin 跨用户看对话因 Aegra SQL 硬过滤 user_id 无法 bypass,单开独立 admin API 直连 aegra DB。
Overview(为什么授权要"挂在项目上"而不是每个资源各管各的)
a-cdm 的业务对象不是孤立的:一个会议属于某日历、日历属于某项目;一份会议文件、一份 BA 报告、一个 NC 挂载点、一个 AI 同事 mount——全都最终归属某个 project_id。如果让每类资源各自维护一张"谁能访问我"的权限表,会出现两个问题:
- 权限漂移:同一个人在项目层是成员,却因为某张子表漏插一行而看不到会议——权限源头分散,对不齐。
- N 张表 N 套判权代码:calendar/meeting/file/report 每个都写一遍"查成员"逻辑,改一次判权规则要改 N 处。
a-cdm 的答案是单一权限源 + 链式反查:权限只记在 project_members 一张表(owner / member 两角色),子资源不存权限,需要判权时顺着外键链反查回 project_id,再复用项目级成员判定。auth/dependencies.py 只放项目级 3 个依赖,auth/resource_deps.py 放资源级反查依赖,后者全部委托前者的 access_enabled() 开关与 ProjectMemberService 成员查询——判权规则只有一份实现。
Architecture
| 组件 | 职责 | 入口文件 | Source |
|---|---|---|---|
Project | 项目根聚合,挂 calendars / mounts;软删字段 deleted_at | model | acdm-backend/app/model/models.py:45-92 |
ProjectMember | 单一权限源:owner / member 两角色,软删 + partial unique | model | acdm-backend/app/model/models.py:205-239 |
ProjectNcMount | 项目 ↔ Nextcloud 目录 1:N 挂载;is_system_managed 跟随改名 | model | acdm-backend/app/model/models.py:284-326 |
ProjectAgentMount | 项目 ↔ deer-flow AI 同事(manager/expert)挂载,软删 | model | acdm-backend/app/model/models.py:242-281 |
require_project_access | 项目级:成员 / admin 放行,否则 40401 | dependency | acdm-backend/app/auth/dependencies.py:45-70 |
require_project_owner | 项目级:owner / admin 放行,普通成员 40301 | dependency | acdm-backend/app/auth/dependencies.py:73-101 |
require_admin | 全局:is_admin 才放行,否则 HTTP 403 | dependency | acdm-backend/app/auth/dependencies.py:104-110 |
resource_deps.* | 子资源链式反查 → 复用项目判权 | dependency | acdm-backend/app/auth/resource_deps.py:36-152 |
ProjectMemberService | 成员增删查 + 角色判定 + 最后 owner 保护 | service | acdm-backend/app/services/project_member_service.py:38-218 |
ProjectService | 项目 CRUD + 改名 NC 迁移 + 软删整树 | service | acdm-backend/app/services/project_service.py:34-393 |
ProjectSOWService | SOW 文件 → LLM → 结构化 JSON | service | acdm-backend/app/services/project_sow_service.py:38-171 |
| admin_thread_router | admin 跨用户看对话(直连 aegra DB) | router | acdm-backend/app/api/admin_thread_router.py:77-336 |
数据模型(项目根聚合 + 三张关系表 + 内容树):
Components / Subsystems
Project 模型与四张关系表
职责:Project(models.py:45)是业务域根。注意 owner_id(models.py:50)是冗余的 SSO sub,真正的权限源是 project_members 表——owner_id 只是创建者标记,判权代码从不读它。两个文档相关字段:sow_data(JSONB,LLM 解析的工作说明书,models.py:76-80)、gitlab_wiki_path(知识库挂载相对路径,models.py:70-75)。nextcloud_path(models.py:63-69)已 DEPRECATED,挂载迁到 ProjectNcMount 表。
三张关系表各管一类隶属:
ProjectMember(models.py:205-239):权限源。role是owner/member枚举。关键设计是软删 + partial unique——deleted_at软删字段 +project_members_project_user_active_idx部分唯一索引WHERE deleted_at IS NULL(models.py:230-236)。这让"踢出成员→再加回"成为可能:remove 是软删,再 add 时旧软删行不阻挡唯一约束。ProjectNcMount(models.py:284-326):一个项目可挂多个 Nextcloud 目录(1:N)。is_system_managed=True的那条 default mount,nc_path 跟随project.name改名自动同步、不可删;用户加挂的is_system_managed=False不随改名变。ProjectAgentMount(models.py:242-281):项目 ↔ deer-flow custom agent。role=manager是每项目唯一的"项目大管家"(自动 provision),role=expert是挂载的领域专家 clone。卸载是软删(unmounted_at),不删 deer-flow 侧 agent + memory。跨用户共享:同项目所有成员看同一份 mount。
三级授权依赖
职责:把"谁能做什么"压缩成三个 FastAPI dependency,handler 只需 Depends(...) 声明所需级别。
require_project_access(dependencies.py:45-70)——成员级。逻辑顺序很讲究:① db.get(Project) 不存在或软删 → BusinessException(40401);② 降级开关 access_enabled() 为 false 直接返回(回 Phase 1 行为);③ user.is_admin 直接放行;④ ProjectMemberService.is_member 否则 40401。注意非成员返回的是 40401 "Project not found" 而不是 403——有意掩盖项目存在性,不让外人通过错误码探测项目是否存在。
require_project_owner(dependencies.py:73-101)——owner 级。前三步同上;不同在第四步:is_owner 放行;若 is_member(是成员但非 owner)→ BusinessException(40301) "Owner permission required"(语义 HTTP 403);否则 40401。这个两层兜底(先判 owner 再判 member)保证普通成员收到"权限不够"而非"不存在",但外人仍只看到"不存在"。
require_admin(dependencies.py:104-110)——全局管理员。保留 Phase 1 原契约:抛 HTTPException(403) 而非 BusinessException,与项目级依赖错误语义不同(这是历史契约,刻意未统一)。
错误码语义速查:
| 场景 | 抛出 | 对外 HTTP | 语义 |
|---|---|---|---|
| 项目不存在 / 已软删 | BusinessException(40401) | 400 + code 40401 | 掩盖存在性 |
| 非成员非 admin | BusinessException(40401) | 400 + code 40401 | 掩盖存在性 |
| 普通成员尝试 owner 操作 | BusinessException(40301) | 400 + code 40301 | 权限不足(403 语义) |
| 非 admin 访问 admin 端点 | HTTPException(403) | 403 | Phase 1 原契约 |
Source: acdm-backend/app/auth/dependencies.py:9-16
ProjectMemberService:权限源的读写
职责:project_members 表的唯一读写入口,所有判权依赖都经它查。
关键方法:
is_member/is_owner(project_member_service.py:61-84):用select(exists())判定,全部带deleted_at IS NULLfilter——软删成员不算成员。get_role(project_member_service.py:86-94):返回'owner'/'member'/None,前端canManage = isAdmin || my_role === "owner"依赖它显示 owner 专属 UI。add_owner/add_member/remove_member(:42-218):全部包business_transaction——成员变更也是可逆 DB 操作,会 fanoutdb_insert/db_soft_deletesub_event,前端"找回"可一键 revert。remove_member(:185-218):软删 + 最后 owner 保护——若目标是项目唯一 active owner,抛LastOwnerProtection(对外 42210),防止项目变成无主。
add_member 的两道前置校验值得注意(:161-170):先查目标 user 在 acdm_users 且 is_active(否则 UserNotRegistered),再查不是 active 成员(否则 AlreadyMember)——意味着只能邀请"登录过 a-cdm 至少一次"的用户(SSO 首登才会写 acdm_users)。
资源级链式反查
职责:子资源(calendar/meeting/file/report/mount)端点拿到的是资源 ID 而非 project_id,需要顺外键链反查回项目再判权。
resource_deps.py 的核心是私有 helper _check_member_or_raise(resource_deps.py:26-33):access_enabled()=false 或 is_admin 直接放行,否则 is_member 判定——复用 dependencies.py 的同一开关与同一 ProjectMemberService,判权规则零重复。
反查深度由资源在内容树的位置决定:
| 依赖 | 反查链 | 深度 | Source |
|---|---|---|---|
require_calendar_access | calendar.project_id | 1 跳 | acdm-backend/app/auth/resource_deps.py:36-49 |
require_meeting_access | meeting → calendar.project_id | 2 跳 | acdm-backend/app/auth/resource_deps.py:52-73 |
require_file_access | file → meeting → calendar.project_id | 3 跳 | acdm-backend/app/auth/resource_deps.py:130-152 |
require_report_access | report.project_id | 1 跳 | acdm-backend/app/auth/resource_deps.py:76-89 |
require_project_mount | mount.project_id == path project_id | 校验隶属 | acdm-backend/app/auth/resource_deps.py:92-107 |
require_project_mount_owner | 同上 + owner 判定 | 校验隶属 + owner | acdm-backend/app/auth/resource_deps.py:110-127 |
每条链都在反查 SQL 里带上中间节点的 deleted_at IS NULL。例如 require_meeting_access(:64-71)反查 project_id 时 WHERE Calendar.deleted_at IS NULL——孤儿资源(meeting 自己没软删,但所属 calendar 已软删、cascade 没生效)会查不到 project_id,等价不可访问,返 40401。require_file_access(:139-148)是最深的 3 跳:MeetingFile → Meeting → Calendar 两个 JOIN + 双 deleted_at IS NULL。这是"父删子未删"防御:即使 cascade 软删漏了子节点,链式反查也会因中间节点失踪而拒绝访问。
Data Flow / 控制流
创建项目的事务编排
ProjectService.create_project(project_service.py:113-182)在单事务里串起 5 件事,任一失败整体回滚,避免 DB ↔ Nextcloud drift:
关键设计:NC 根目录在 commit 前必须建成(project_service.py:154),否则后续 update_project 改名调 move_project_root 时 MOVE 源端不存在会撞 404——这是 2026-04-25 实验版踩过的坑,注释里写明了。provision_manager(:175-179)是 graceful 的:AI 大管家 provision 失败不阻塞项目创建,可后续 backfill;但 NC mount 记录与项目同事务强一致。
项目改名:NC 物理迁移 + DB 批改
update_project(project_service.py:184-261)在 name 变更时按 ADR-3 顺序 操作,全程包 business_transaction,name 变更失败 NC move 抛异常自动 saga rollback 已成功的 sub-op:
第 3 步只同步 is_system_managed=True 的 mount(project_service.py:249-256):用户自定义挂载即使 nc_path 恰好等于旧默认值也不动——区分"系统托管"与"用户自管"两类挂载是这张表的核心 invariant。gitlab_wiki_path 在写入前有兜底校验(:207-223):trim 后空转 None、不许以 / 开头(GitLab 仓内是相对路径)、不许含 .. 段(防越界)。
软删项目:整树 ORM-loop 软删
delete_project(project_service.py:263-349)是 2026-05-12 Phase 3.6 改造的重点:从硬删改软删,目的是让整个删除可被 admin 一键 revert。
关键实现细节(:298-340):子表(calendars/meetings/meeting_files)必须用 ORM-level 逐行 setattr(SELECT 出来再 cal.deleted_at = now_ts),不能用 Core 级 sa_update()——因为 substrate 的 db_event_recorder listener 只迭代 session.dirty,SQL UPDATE 绕开 ORM identity map 会让事件抓不到改动。代价是 N+1 query,但单项目 entity 通常 < 100,可接受。project_members 行不软删(ADR-1:软删 project 后默认查询已等价无成员关系 + revert 后自动恢复)。NC 物理目录不删(ADR-4:revert 后链路立即可用)。
SOW 解析
ProjectSOWService.analyze_sow(project_sow_service.py:41-171)把上传的工作说明书文件转成结构化 JSON 存进 Project.sow_data。流程:① FileParser.extract_full_text 提文本;② 正则清洗零宽空格/控制字符(:63,docx 提取常带,会导致网关 400);③ 超 6000 字截断(jointpilot/kimi-k2.6 上下文窗口实测约束,留 token 给输出);④ 非流式调 LLM(get_chat_client("sow_analyze"),避免 SSE 空返回);⑤ 多层 JSON 清洗(剥 markdown 代码块、正则抠第一个 {...});⑥ 必填字段兜底补默认值;⑦ 附 _meta 元数据。
注意 kimi-k2.6 的 thinking 兜底(:101):若 content 为空但 reasoning_content 有值,取后者——这是 ai_registry force_overrides 注入 thinking:disabled 仍兜不住时的二次防御。SOW 端点权限是 require_project_access(成员即可,project_sow_router.py:27),解析结果由前端再调 PUT /projects/{id} 持久化(需 owner)。
admin 跨用户访问:为什么要独立 API
admin_thread_router(admin_thread_router.py)是一个绕过常规授权链的特例。常规对话线程走 deer-flow 的 Aegra 运行时,Aegra 0.9.4 在 thread 表 schema 强绑 user_id,API 层 14 处 SQL 硬过滤 WHERE user_id = identity,auth handler 返 None 也不能 bypass(2026-04-25 spike 实证)。所以 admin 要跨用户看所有人的对话,只能绕开 Aegra HTTP API,直连 platform-db 的 aegra DB(_aegra_db_url() 从 acdm-backend 自己的 DATABASE_URL 衍生,admin_thread_router.py:46-65)。
这个端点:① 挂 require_admin(只读,不提供改/删他人 thread);② 完整 thread state(messages/artifacts/todos)走官方 AsyncPostgresSaver.aget() 反序列化 channel_values——SQL 直读 bytea blob 等于 fork LangGraph 内部 ABI,故用 SDK;③ 反序列化失败 graceful 降级(三字段返 None + messages_warning,端点不 500,admin 仍能看 metadata + runs 审计,:168-235);④ 独立降级开关 ACDM_ADMIN_THREAD_ACCESS_ENABLED=false → 503。
扩展指南:新增一个项目级受保护端点
python
# app/api/my_feature_router.py
from fastapi import APIRouter, Depends
from app.auth.dependencies import require_project_access # 或 require_project_owner
from app.model.models import Project
router = APIRouter()
@router.get("/{project_id}/my-feature")
async def get_my_feature(
project_id: str,
_project: Project = Depends(require_project_access), # 判权 + 返回已校验的 Project ORM
):
# 到这里 project 已确认存在且当前用户有权;直接用 _project
...若要保护子资源端点(只有 resource_id 没有 project_id),新增一个反查依赖到 resource_deps.py:
python
async def require_wbs_access(
wbs_id: str,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
wbs = await db.get(Wbs, wbs_id)
if wbs is None or wbs.deleted_at is not None:
raise BusinessException(40401, "WBS not found")
# 反查到 project_id(带中间节点 deleted_at IS NULL,防孤儿资源)
project_id = ... # select(...).where(parent.deleted_at.is_(None))
if project_id is None:
raise BusinessException(40401, "WBS not found")
await _check_member_or_raise(db, user, project_id) # 复用同一判权
return wbs约束清单(从代码校验逻辑读出):
- 必须复用
_check_member_or_raise(resource_deps.py:26),不要自己写成员查询——否则ACDM_PROJECT_ACCESS_ENABLED降级开关对你的端点失效。 - 资源不存在 / 软删 / 反查链断 一律返
BusinessException(40401)同一文案,掩盖存在性(与 dependencies.py 对齐)。 - 反查 SQL 必须带中间节点
deleted_at IS NULL,否则父删子未删的孤儿资源会越权可见。 - owner 级写端点用
require_project_owner或仿require_project_mount_owner(:110-127)的"先 owner 后 member 两层兜底"模板。 - router 注册时项目级 prefix
/projects,挂dependencies=auth_dep(全局先认证,main.py:378-427)。
Configuration
| Config | 默认 | 含义 | 影响 | Source |
|---|---|---|---|---|
ACDM_PROJECT_ACCESS_ENABLED | true | 项目/资源级成员判权总开关 | 设 false/0/no → 所有成员校验放行,回 Phase 1"只查存在性"(紧急回滚不需改代码) | acdm-backend/app/auth/dependencies.py:33-42 |
ACDM_ENFORCE_IS_ACTIVE | true | 是否拦截 is_active=false 用户 | false → 禁用用户旁路认证 | acdm-backend/app/util/auth.py:19-25 |
ACDM_ADMIN_THREAD_ACCESS_ENABLED | true | admin 跨用户看对话端点开关 | false → 端点返 503 | acdm-backend/app/api/admin_thread_router.py:68-74 |
AEGRA_DATABASE_URL | (从 DATABASE_URL 衍生) | admin_thread_router 直连的 aegra DB | 显式设可分离 DB 实例 | acdm-backend/app/api/admin_thread_router.py:46-65 |
安全相关:
ACDM_PROJECT_ACCESS_ENABLED=false是全局放开授权层的紧急开关——一旦设 false,所有项目/资源级判权失效(只剩"存在性检查"),任何登录用户能访问任何项目。仅作授权层故障时的应急降级,改 .env 后须up -d --force-recreate(restart 不重读 .env)。
Common Pitfalls / 实战 Tips
owner_id不是权限源:Project.owner_id(models.py:50)只是创建者冗余标记,判权代码从不读它。改授权逻辑别动这个字段,要动project_members。- 40401 不代表项目真不存在:非成员访问也返 40401 "Project not found"——这是有意掩盖存在性,排查时别被误导,真不存在和无权限对外无法区分(查 DB 才知)。
- 软删 member 仍占 partial unique 之外:partial unique 是
WHERE deleted_at IS NULL,所以"踢出再加回"能成功;但若想物理唯一约束会撞——这是设计。 - 改名时只同步 system-managed mount:用户自定义 mount 的 nc_path 即使等于旧默认值也不会跟着改名变(
project_service.py:249-256),这是 invariant 不是 bug。 - delete_project 必须 ORM loop 不能 Core update:用
sa_update()批量软删子表会绕开 substrate 事件 listener,导致软删事件抓不到、无法 revert(project_service.py:298-302注释明示)。 - admin 看对话不能用 LangGraph SDK 跨用户:Aegra SQL 硬过滤 user_id,handler 返 None 也 bypass 不了,必须直连 aegra DB(
admin_thread_router.py:1-25)。
References
acdm-backend/app/auth/dependencies.py:1-110— 三级项目授权依赖 + 降级开关(本章核心)acdm-backend/app/auth/resource_deps.py:1-152— 资源级链式反查依赖,复用项目判权acdm-backend/app/services/project_member_service.py:1-218— 权限源读写 + 最后 owner 保护acdm-backend/app/services/project_service.py:1-393— 项目 CRUD 事务编排 + 改名迁移 + 软删整树acdm-backend/app/model/models.py:45-326— Project / ProjectMember / ProjectNcMount / ProjectAgentMountacdm-backend/app/services/project_sow_service.py:1-171— SOW 文件 → LLM → 结构化 JSONacdm-backend/app/api/admin_thread_router.py:1-336— admin 跨用户对话浏览(绕 Aegra SQL filter)acdm-backend/app/util/auth.py:1-69— get_current_user 认证 → CurrentUseracdm-backend/app/api/project_router.py:1-93— /projects CRUD 端点 + 依赖装配
Related Pages
| Page | Relationship |
|---|---|
| 鉴权与授权双层体系 | 本章授权层是该章"双层"中的授权层;get_current_user 认证在该章详解 |
| acdm-backend 控制面架构 | 本章 router/service/model 分层是该章控制面的具体业务域实例 |
| 可逆 DB 操作与存储层 | 本章 create/delete_project 与成员变更包的 business_transaction 在该章详解 |
| 会议域与 AI 原子抽取 | 本章 Calendar→Meeting→MeetingFile 内容树是该章业务对象,资源级反查保护其端点 |
| Aegra 运行时与 LangGraph | 本章 admin_thread_router 绕过的 Aegra SQL user_id 过滤在该章说明 |
| BA 专家报告工作流 | 本章 require_report_access 链式反查保护该章 Report 资源 |