Skip to content

可逆 DB 操作与存储层

本章目标:

  1. 理解 a-cdm 如何让"AI / 用户对 Nextcloud 与数据库的写操作可被撤销"——Actor 四模式、事件溯源表、saga 事务的完整链路
  2. 看懂 ReversibleStorageClient(NC 软删/备份)与 db_event_recorder(ORM 快照)如何用同一张 user_storage_events 表统一审计 + revert
  3. 弄清存储层底座:Nextcloud WebDAV 客户端、ProjectStorage 三层越界守护、配额与多重 kill switch、cron prune 保留策略

TL;DR

a-cdm 的写操作(改 NC 文件、改业务行)不是"做完就忘",而是每一笔都先记一条 append-only event 到 user_storage_events 表,NC 写还先把旧文件 MOVE 进 .acdm-trash / COPY 进 .acdm-versions。"谁在写"由 Actor(direct/via_agent/system/unknown 四模式,ContextVar 透传,deny-by-default)界定;"一次用户视角原子操作"由 business_transaction 框定,异常时 saga 倒序 rollback 已成功的 sub-events。RevertEngineoperation_type 反向执行(DELETE / COPY 回 / MOVE 回 / UPDATE deleted_at=NULL),带 ETag 乐观锁与备份过期检测。底层 StorageClient 是裸 WebDAV,ProjectStorage 给项目母目录操作加三层越界守护,cron 按保留天数 prune 备份并把 event 标 expired

Overview(为什么要"可逆")

a-cdm 把 AI agent 当成"用户授权下的代理"(见 鉴权与授权双层体系)。AI 会自动改文件、推进 milestone、删会议——这带来一个尖锐问题:AI 操作错了怎么办?Nextcloud 没有事务,WebDAV DELETE 一个目录就是真没了;PostgreSQL 有事务但单个 HTTP 请求 commit 后也回不去了。

直觉做法是"出错时再想办法补救",但这有三个致命缺陷:

  1. NC 删除不可逆:WebDAV DELETE 递归删目录,删完即灭,Nextcloud 自带回收站靠不住(权限/配额/保留期不可控,见 client.py:5 D2 决策)
  2. 跨 NC+DB 的复合操作没有原子性:删一个会议 = 软删 DB 行 + 删 NC 附件目录,中间任一步失败,系统处于半完成的脏态
  3. 审计真空:出问题想追"是谁(用户?哪个 AI?)在什么意图下做的"完全无据可查

a-cdm 的答案是一套事件溯源 + saga的可逆 substrate:每个写操作先备份(NC)/ 快照(DB)再执行,并 append 一条带完整 actor + pre/post 状态的 event;business_transaction 把"用户视角一次操作"框成一个 saga,异常时倒序 revert;事后还能由 admin/owner/本人通过 API 主动 revert 任意 event 或整个 transaction。

Architecture

可逆 substrate 分四层:身份层(谁在写)→ 事务层(框一次操作)→ 执行层(NC wrapper + DB recorder)→ 溯源/回滚层(事件表 + RevertEngine)。底下垫一层裸存储(StorageClient / ProjectStorage)。

组件职责入口文件Source
Actor / ActorModeuser-centric 四模式身份,ContextVar 透传,deny-by-defaultactor.pyacdm-backend/app/storage/reversible/actor.py:50-95
system_actorcron/migration 的 with 上下文,token 栈精确恢复system_actor.pyacdm-backend/app/storage/reversible/system_actor.py:23-38
business_transaction框一次原子业务操作,异常 saga 倒序 rollbacktransaction.pyacdm-backend/app/storage/reversible/transaction.py:64-134
ReversibleStorageClient装饰 StorageClient,5 写方法改 async + 备份 + 限额 + 记 eventclient.pyacdm-backend/app/storage/reversible/client.py:101-574
db_event_recorderSQLAlchemy after_flush listener,ORM 变更抓 row 快照db_event_recorder.pyacdm-backend/app/storage/reversible/db_event_recorder.py:264-313
StorageEventsServiceuser_storage_events 表 append-only 写 + 查询 + 限额累计storage_events_service.pyacdm-backend/app/services/storage_events_service.py:42-237
RevertEngineoperation_type 反向执行,ETag 乐观锁revert_engine.pyacdm-backend/app/services/revert_engine.py:43-613
StorageClient裸 Nextcloud WebDAV 客户端(PUT/DELETE/MOVE/PROPFIND)nextcloud.pyacdm-backend/app/storage/nextcloud.py:59-101
ProjectStorage项目母目录操作的三层越界守护 facadeproject_storage.pyacdm-backend/app/storage/project_storage.py:34-199
prune_acdm_trash / prune_db_soft_deletedcron 清理备份/软删行,event 标 expiredscripts/acdm-backend/scripts/prune_acdm_trash.py:161-239

Components / Subsystems

Actor:user-centric 四模式身份

职责:回答"这笔写是谁发起的"。

关键类:Actor(Pydantic frozen model)在 acdm-backend/app/storage/reversible/actor.py:50-95,ActorModeLiteral["direct","via_agent","system","unknown"](actor.py:39)。

a-cdm 没用传统的"human / ai / system 三类并列"模型,而是 user-centric:AI 永远是用户授权下的代理,操作主体始终是用户,只有"动手方式"分两种(actor.py:1-13)。四模式的字段约束由 _check_mode_constraints model_validator 强校验(actor.py:67-95):

modeuser_idagent_idthread_id谁 setSource
direct必填(JWT sub)FastAPI middleware set_direct_actoracdm-backend/app/storage/reversible/actor.py:124-128
via_agent必填(thread owner)必填必填MCP/langgraph 入口 set_via_agent_actoracdm-backend/app/storage/reversible/actor.py:131-148
system禁(=null)必填(script_name)with system_actor("...")acdm-backend/app/storage/reversible/system_actor.py:23-38
unknown全 null全 null全 null默认值,wrapper 拒写acdm-backend/app/storage/reversible/actor.py:98-102

实现要点:

  • deny-by-default:ContextVar 默认值是 _UNKNOWN_ACTOR(actor.py:98-102),require_actor()(actor.py:110-121)检测到 unknown 直接抛 UnknownActorError。设计动机:防止"漏 set actor 后操作被错记成 direct"——宁可拒绝写,也不产生错误归属(actor.py:42-48)。
  • ContextVar 0 改动透传:set_direct_actor 在 FastAPI HTTP middleware 解析 JWT cookie 后调一次(acdm-backend/app/main.py:239-250),整个请求链(每个 HTTP 请求是独立 task)的任何写代码都能从 current_actor() 读到,接入方代码零改动。
  • via_agent 入口:harness/MCP 写工具走 agent_tools_nc/dependencies.py 解析 X-ACDM-* 三 header 后 set_via_agent_actor(acdm-backend/app/api/agent_tools_nc/dependencies.py:32-57),AI 的写归到授权它干活的那个用户名下。

business_transaction:一次原子业务操作 + saga

职责:把"用户视角的一次操作"(删一个会议 = 软删 DB + 删 NC 目录)框成一个 saga,出错倒序 rollback。

关键类:TransactionContext(dataclass)在 acdm-backend/app/storage/reversible/transaction.py:28-40,business_transaction async context manager 在 transaction.py:64-134

进入时生成 UUID v4 当 txn_id,通过 transaction_var ContextVar 透传——wrapper 的 _recorddb_event_recorder 都自动从中取 txn_id 写进 event(transaction.py:1-14)。退出时分两路:

  • 正常:_drain_pending_tasks 等所有 db event 异步 task 落表(transaction.py:136-155),保证 API response 返回时 events 已可见
  • 异常:先 drain 让 sub_event_ids 完整,再 _saga_rollback(transaction.py:158-189)按 id 倒序(更晚的先撤)逐条调 RevertEngine.revert_event,rollback actor 是 system 模式(saga_rollback:<op>),rollback 自身失败仅 ERROR log 不嵌套异常,然后 re-raise 原业务异常

实现要点:

  • 嵌套复用 outer:project_member_service.add_ownercreate_project 调用时,inner business_transaction 不创建新 txn,沿用 outer,inner 退出不触发 rollback(由 outermost 决定边界,transaction.py:92-100)
  • drain 的必要性:db_event_recorderon_commit listener 是 sync,内部 loop.create_task 异步调度写 event;若不 await 这些 task,sub_event_ids 不完整 → saga rollback 漏掉 events、API response 时 events 还没落表(transaction.py:136-155)

ReversibleStorageClient:NC 写的可逆 wrapper

职责:装饰裸 StorageClient,把 5 个写方法变 async 并叠加 actor 校验 / kill switch / 限额 / 备份 / 记 event。

关键类:ReversibleStorageClient(StorageClient)acdm-backend/app/storage/reversible/client.py:101-574

subclass StorageClient——读方法天然继承(同步、不变),只 override 5 个写方法为 async,每个用 asyncio.to_thread(super().xxx, ...) 桥接到底层 sync 调用(client.py:14-19)。

写方法备份动作event operation_typeSource
upload_file覆盖前 COPY 旧文件 → .acdm-versions/<path>/<event_id>/upload_create / upload_overwriteacdm-backend/app/storage/reversible/client.py:355-400
upload_fileobj读 bytes 转走 upload_file 同逻辑同上acdm-backend/app/storage/reversible/client.py:402-411
delete_file删前 MOVE → .acdm-trash/<yyyy-mm-dd>/<path>delete_fileacdm-backend/app/storage/reversible/client.py:413-448
delete_dir删前 MOVE 整目录 → .acdm-trash/...delete_diracdm-backend/app/storage/reversible/client.py:450-476
mkdir无(记 created/existed 区分)dir_createacdm-backend/app/storage/reversible/client.py:478-538
move_dir无(反向 MOVE 即可)moveacdm-backend/app/storage/reversible/client.py:540-573

实现要点:

  • ETag 乐观锁:写前 _propfind_metadata 取 NC fileid 当 etag,记进 event 的 pre/post state(client.py:134-157);revert 时比对当前 etag 是否变过(被别人改过则拒)
  • 大文件只记 metadata:_backup_overwrite 中文件 ≥ ACDM_AI_NC_BACKUP_THRESHOLD_MB(默认 50MB)只在 pre_state 记 metadata 不实际 COPY(client.py:159-199),revert 时返 410 提示走 NC 客户端
  • 防递归:.acdm-trash / .acdm-versions 内部路径 _is_internal_path 检测后直接走底层,不再叠 wrapper(否则备份操作自己又触发备份,死循环,client.py:81-84)
  • 审计失败不阻塞业务:_record 用独立 async_session_maker() session,best-effort,失败只 ERROR log(client.py:316-351)
  • event_id 预分配:覆盖场景从 PostgreSQL nextval 序列预分配 id(client.py:114-120),让 .acdm-versions 备份路径里能含确定性的 <event_id>
  • ASCII Destination header:_ascii_url(client.py:47-67)解决 NEXTCLOUD_BASE_PATH 含中文时,httpx 对 header value 强制 ASCII 会抛 UnicodeEncodeError 的坑

db_event_recorder:ORM 变更的事件记录器

职责:在 business_transaction context 内,自动把 SQLAlchemy ORM 的 INSERT/UPDATE/DELETE 变更抓成 row 快照,写成 db sub-event。

关键类:模块级 SQLAlchemy event listener,_on_flush / _on_commit / _on_rollbackacdm-backend/app/storage/reversible/db_event_recorder.py:264-313

ORM 变更operation_typepre_statepost_stateSource
INSERTdb_createNonerow snapshotacdm-backend/app/storage/reversible/db_event_recorder.py:181-192
UPDATE(deleted_at null→非null)db_soft_delete变更前变更后acdm-backend/app/storage/reversible/db_event_recorder.py:96-110
其他 UPDATEdb_update变更字段前值变更字段后值acdm-backend/app/storage/reversible/db_event_recorder.py:197-232
DELETE(hard)db_hard_deleterow snapshotNone(Phase 1 不可 revert)acdm-backend/app/storage/reversible/db_event_recorder.py:235-250

实现要点:

  • first-check 早退:_on_flush 先查 current_transaction() 是否非 None,否则 no-op 早退——90% 的 DB 写不在 transaction 内,只对显式 business_transaction 标记的业务操作记 event(db_event_recorder.py:264-274),不污染所有 DB 写
  • 模块级注册:listener 必须 import 即注册(db_event_recorder.py:255-274)。install_db_event_recorder() 现在只是 no-op 锚点(db_event_recorder.py:316-325)。历史教训:Phase 1 把注册放在 lifespan 调的函数里,测试不经 lifespan → listener 不注册 → DB events 不落
  • 大字段兜底:单字段序列化超 _MAX_FIELD_BYTES(32KB)记占位字符串 <skipped:size=N>,防 event 表膨胀(db_event_recorder.py:64-93);revert 时跳过 placeholder 字段
  • user_storage_events 自身跳过:在 _SKIP_TABLES,否则给 event 表写 event 会无限递归(db_event_recorder.py:49-51)
  • 自动列跳过:_AUTO_COLUMNS(created_at/updated_at)由 DB trigger 维护,反向 apply 时跳过;但 deleted_at 不算——它是软删信号,revert 需要 unset(db_event_recorder.py:41-45)

RevertEngine:按 operation_type 反向执行

职责:对单个 event / 一批 event / 整个 transaction 执行 revert。

关键类:RevertEngineacdm-backend/app/services/revert_engine.py:43-613,_dispatch 按 operation_type 路由(revert_engine.py:156-184)。

operation_typerevert 逻辑Source
upload_createDELETE 该文件(校 ETag)acdm-backend/app/services/revert_engine.py:188-194
upload_overwrite.acdm-versions COPY 回(校 ETag + 备份存在)acdm-backend/app/services/revert_engine.py:196-208
delete_file / delete_dir.acdm-trash MOVE 回acdm-backend/app/services/revert_engine.py:210-228
move反向 MOVE(dst→src)acdm-backend/app/services/revert_engine.py:230-243
dir_createcreated=True 才 RMDIR;existed=True no-opacdm-backend/app/services/revert_engine.py:245-280
db_soft_deleteUPDATE ... SET deleted_at=NULLacdm-backend/app/services/revert_engine.py:417-439
db_updateUPDATE ... SET <col>=pre_state.<col>acdm-backend/app/services/revert_engine.py:441-485
db_createDELETE FROM ... WHERE id=pk(graceful no-op)acdm-backend/app/services/revert_engine.py:487-502
db_hard_deletePhase 1 不支持,抛 50010acdm-backend/app/services/revert_engine.py:179-183

实现要点:

  • revert 不经 wrapper:revert 的 NC 操作直接用 raw StorageClient(revert_engine.py:46-49),否则会再写一条 wrapper 生成的 event,语义混乱。revert 完只 append 一条 operation_type=revert / references_event_id=<orig> 的审计 event
  • ETag 乐观锁拒改:_verify_etag_unchanged(revert_engine.py:284-301)发现 path 当前 etag ≠ event 记录值 → 抛 BusinessException(40901)"已被其他操作修改,请人工核实"
  • 备份过期 410:_verify_backup_exists(revert_engine.py:335-354)发现备份已被 cron prune → 抛 41010 Gone,提示走 NC 客户端 Files Versions 手工恢复
  • transaction-level revert 是 saga partial:revert_transaction(revert_engine.py:506-613)拉同 transaction_id 全部 committed sub-events 倒序 revert,部分失败继续后续,append 一条 revert_transaction event,API 层返 207 Multi-Status;用 already_reverted 子查询防重复 revert(revert_engine.py:521-545)

StorageClient 与 ProjectStorage:存储底座

职责:StorageClient 是裸 Nextcloud WebDAV 客户端(从 v0.2.1 零改动搬来);ProjectStorage 给"项目母目录"危险操作加越界守护。

StorageClient(acdm-backend/app/storage/nextcloud.py:59-101)用 httpx + BasicAuth 调 WebDAV:upload_file(PUT)/ delete_file(DELETE)/ move_dir(MOVE + Overwrite:F)/ list_dir(PROPFIND Depth:1 解析 XML)/ is_dir_empty(数 <d:response> 个数)。支持任意 NC 绝对路径作 base(base_path 参数),ensure_base=False 用于人工管理的"项目文档"挂载路径——缺失即 fail-fast 而非自动建目录(nextcloud.py:115-131)。

ProjectStorage(acdm-backend/app/storage/project_storage.py:34-199)是 facade:StorageClient 不持有"这个 key 属于哪个项目"的概念,直接调 delete_dir 可能传错 key 误删别的项目。它以 project_name 为根边界,_assert_under_root(project_storage.py:175-199)做三层守护:

  1. 层 1 _assert_name_valid:project_name 不得空 / 含 / \ / .. / ASCII 控制字符
  2. 层 2:key 必须 == root 自身或以 {root}/ 起头
  3. 层 3:各段不含 ..(挡 root/../other 这种起头合法但中间穿越的)

任一层失败立即抛 BusinessException(50010),不向 WebDAV 发请求。注意 ProjectStorage 内部持有的底层是 ReversibleStorageClient(project_storage.py:21),所以项目改名/删除也走可逆链路。

Data Flow

删一个会议(NC + DB 复合操作 + saga)

主动 revert 一笔 NC 上传覆盖

Implementation Details:event 状态机与 append-only 纪律

user_storage_events 是事件源核心表(acdm-backend/app/model/models.py:355-466),铁律是业务代码只能 append,不能 UPDATE / DELETE 行(models.py:361-363)。StorageEventsService.record()flushcommit(commit 由调用方控制,与业务事务共享),(acdm-backend/app/services/storage_events_service.py:50-115)。

唯一允许 UPDATE 的特例是 mark_expired_for_paths()——仅 cron prune 调用,且 service 层硬断言 current_actor().mode == "system",否则抛 PermissionError(storage_events_service.py:204-237)。这是用代码强制"只有 system actor 能改 event 状态"。

daily_via_agent_bytes()(storage_events_service.py:170-200)用 SUM(post_state->>'bytes') 累计当日 mode=via_agent + status=committed 的写入字节(failed 不消耗配额),供 wrapper 实施限额。

速查表:operation_type 全集

operation_type产生方备份位置可 revertrevert 动作Source
upload_createRSC.upload_file(新建)DELETE 文件acdm-backend/app/services/revert_engine.py:188-194
upload_overwriteRSC.upload_file(覆盖).acdm-versionsCOPY 回acdm-backend/app/services/revert_engine.py:196-208
delete_fileRSC.delete_file.acdm-trashMOVE 回acdm-backend/app/services/revert_engine.py:210-218
delete_dirRSC.delete_dir.acdm-trashMOVE 回(目录)acdm-backend/app/services/revert_engine.py:220-228
moveRSC.move_dir反向 MOVEacdm-backend/app/services/revert_engine.py:230-243
dir_createRSC.mkdircreated→RMDIRacdm-backend/app/services/revert_engine.py:245-280
db_createORM INSERTrow snapshotDELETE 行acdm-backend/app/services/revert_engine.py:487-502
db_updateORM UPDATEpre snapshot字段回写acdm-backend/app/services/revert_engine.py:441-485
db_soft_deleteORM UPDATE deleted_at前态deleted_at=NULLacdm-backend/app/services/revert_engine.py:417-439
db_soft_undeleterevert 软删触发(审计)acdm-backend/app/storage/reversible/db_event_recorder.py:108-109
db_hard_deleteORM DELETE / cron prunerow snapshotPhase 1 不支持acdm-backend/app/services/revert_engine.py:179-183
revertRevertEngine不可 revert revertacdm-backend/app/services/revert_engine.py:60-63
revert_transactionRevertEngine txn 级acdm-backend/app/services/revert_engine.py:584-601

扩展指南:把一个 service 改成可逆

要让某个 service 的 NC + DB 写变得可被撤销,只需两步(从 meeting_service / file_service 现有实践读出):

python
# 1. import 切换:裸 StorageClient → ReversibleStorageClient(同名同形,改 async)
from app.storage.reversible import ReversibleStorageClient as StorageClient
from app.storage.reversible import business_transaction

# 2. 把"一次用户视角原子操作"用 business_transaction 框起来,NC 写加 await
async def delete_meeting(self, meeting_id: str) -> None:
    async with business_transaction("delete_meeting", meeting_id=meeting_id):
        await self.meeting_repo.soft_delete(meeting_id)   # → db_soft_delete sub-event
        await storage.delete_dir(meeting_dir_key)          # → delete_dir sub-event(自动备份)

约束清单(从代码校验逻辑读出):

  • 必须先 set actor:接入路径上游必须有 set_direct_actor(HTTP middleware 已全局 wire,acdm-backend/app/main.py:239-250)或 set_via_agent_actor(MCP dep)或 system_actor;否则 require_actor()UnknownActorError,写被拒(acdm-backend/app/storage/reversible/actor.py:110-121)
  • NC 写方法必须加 await:wrapper 把 5 个写方法改成了 async(acdm-backend/app/storage/reversible/client.py:14-19),迁移老 service 时调用点必须补 await
  • DB sub-event 只在 transaction 内记:不包 business_transaction 的 ORM 写不会进 event 表(db_event_recorder.py:268-269),这是设计——只审计显式标记的业务操作
  • business_op_name 用动词短语:如 "delete_meeting" / "rename_project",会落到 event.business_op_name 供前端「找回」按钮判断 single-event vs transaction revert(acdm-backend/app/api/admin_storage_events_router.py:53-56)
  • 可逆 DB 操作必须走 ORM:db_event_recorder 靠 SQLAlchemy ORM session 的 after_flush 抓变更;Core-SQL DELETE/UPDATE 不触发 listener(cron prune 因此手工调 _record_db_event,见 prune_db_soft_deleted.py:127-133)

Configuration

Config默认含义影响Source
ACDM_AI_NC_WRITES_ENABLEDTruekill switch:false=所有 via_agent 写被拒(40310)仅 via_agent;direct/system 不受影响acdm-backend/app/config.py:184-190
ACDM_AI_NC_DAILY_BYTES_CAP10 GBvia_agent 单日写入字节上限,超返 40312仅 via_agent 累计acdm-backend/app/config.py:192-195
ACDM_AI_NC_BACKUP_THRESHOLD_MB50≥ 此值只记 metadata 不复制文件体revert 大文件返 410acdm-backend/app/config.py:209-215
ACDM_REVERSIBLE_STORAGE_BYPASSFalse应急 bypass:true=wrapper 退化为直调底层(无 event 无备份)全模式失去可逆性acdm-backend/app/config.py:217-223
ACDM_AI_NC_TRASH_RETENTION_DAYS90.acdm-trash/<date>/ 保留天数cron prune 读取,过期标 expiredacdm-backend/app/config.py:197-199
ACDM_DB_PRUNE_RETENTION_DAYS90软删行保留天数,0=关闭 auto prunecron 物理删acdm-backend/app/config.py:204-207
NEXTCLOUD_BASE_PATHNC 挂载根(可含中文)_ascii_url 须 percent-encodeacdm-backend/app/config.py:26
STORAGE_VERIFY_SSLTruehttpx 校验 NC TLS 证书自签名环境需关acdm-backend/app/config.py:30

安全相关:ACDM_AI_NC_WRITES_ENABLED=false(冻 AI 写)和 ACDM_REVERSIBLE_STORAGE_BYPASS=true(放弃可逆性)是两个紧急降级开关。改后必须 up -d --force-recreate(config.py:188,对齐 lessons L88——restart 不重读 .env)。kill switch 只约束 via_agent,人类 direct 写和 cron system 写永不受影响,这是有意的——出问题先冻 AI,不波及人。

Common Pitfalls / 实战 Tips

  • 漏 set actor → 写直接被拒:不是静默错记成某种 mode,而是 UnknownActorError(actor.py:42-48)。新接入写路径前确认上游 set 过 actor。
  • 中文 BASE_PATH + WebDAV Destination header:NEXTCLOUD_BASE_PATH 含中文时,httpx 对 request URL 自动 percent-encode 但对 header value 强制 ASCII。必须用 _ascii_url 构造 Destination(client.py:47-67),否则软删/备份抛 UnicodeEncodeError('ascii')
  • 大文件 revert 返 410 是预期:≥ 50MB 文件只记 metadata 不备份(client.py:159-174),revert 时 41010 Gone 提示走 NC 客户端 Files Versions,这是设计不是 bug。
  • Core-SQL 写不进 event 表:db_event_recorder 只 hook ORM session;裸 session.execute(update(...)) 不触发(db_event_recorder.py:264-274)。需可逆的 DB 写必须走 ORM 对象 + business_transaction
  • bypass 是单向降级:ACDM_REVERSIBLE_STORAGE_BYPASS=true 期间产生的写无 event 无备份,事后无法 revert(等同 substrate 上线前行为,client.py:108-110),仅 substrate 严重 bug 时应急。

References

  • acdm-backend/app/storage/reversible/actor.py:1-158 — Actor 四模式 + ContextVar 透传 + deny-by-default
  • acdm-backend/app/storage/reversible/transaction.py:1-189 — business_transaction + saga 倒序 rollback + drain
  • acdm-backend/app/storage/reversible/client.py:1-574 — ReversibleStorageClient(5 写方法 + 备份 + 限额 + ETag)
  • acdm-backend/app/storage/reversible/db_event_recorder.py:1-325 — SQLAlchemy after_flush ORM 快照 recorder
  • acdm-backend/app/services/revert_engine.py:1-613 — RevertEngine 按 operation_type 反向 + transaction 级 revert
  • acdm-backend/app/services/storage_events_service.py:1-244 — user_storage_events append-only service + 限额累计
  • acdm-backend/app/storage/nextcloud.py:1-590 — 裸 Nextcloud WebDAV 客户端(PUT/DELETE/MOVE/PROPFIND)
  • acdm-backend/app/storage/project_storage.py:1-200 — ProjectStorage 三层越界守护 facade
  • acdm-backend/app/model/models.py:355-466 — UserStorageEvent 表结构 + 索引
  • acdm-backend/app/api/admin_storage_events_router.py:1-390 — 活动页 list/detail/revert/batch/transaction 端点
  • acdm-backend/scripts/prune_acdm_trash.py:1-564 — NC 备份 cron prune + event 标 expired
  • acdm-backend/scripts/prune_db_soft_deleted.py:1-231 — 软删行 cron 物理删 + fanout db_hard_delete
  • acdm-backend/app/config.py:182-223 — kill switch / 配额 / bypass / 保留天数配置
PageRelationship
项目管理与资源授权ProjectStorage 服务于该章项目母目录操作;permanent_delete 走本章 business_transaction
鉴权与授权双层体系本章 Actor 的 user_id 来自该章 JWT sub;via_agent 归属授权用户
审计、SSE 与后台任务user_storage_events 是该章活动页/审计的事件源
知识源 MCP 与 AgentToolsBFFAI 写经 agent_tools_nc 入口 set_via_agent_actor 接入本章可逆链路
acdm-backend 控制面架构本章 set_direct_actor 由该章 HTTP middleware 全局 wire
CICD 与生产部署运维本章 prune cron 与 kill switch env 由该章部署流程管理

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析