主题
可逆 DB 操作与存储层
本章目标:
- 理解 a-cdm 如何让"AI / 用户对 Nextcloud 与数据库的写操作可被撤销"——Actor 四模式、事件溯源表、saga 事务的完整链路
- 看懂
ReversibleStorageClient(NC 软删/备份)与db_event_recorder(ORM 快照)如何用同一张user_storage_events表统一审计 + revert- 弄清存储层底座: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。RevertEngine 按 operation_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 后也回不去了。
直觉做法是"出错时再想办法补救",但这有三个致命缺陷:
- NC 删除不可逆:WebDAV
DELETE递归删目录,删完即灭,Nextcloud 自带回收站靠不住(权限/配额/保留期不可控,见client.py:5D2 决策) - 跨 NC+DB 的复合操作没有原子性:删一个会议 = 软删 DB 行 + 删 NC 附件目录,中间任一步失败,系统处于半完成的脏态
- 审计真空:出问题想追"是谁(用户?哪个 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 / ActorMode | user-centric 四模式身份,ContextVar 透传,deny-by-default | actor.py | acdm-backend/app/storage/reversible/actor.py:50-95 |
system_actor | cron/migration 的 with 上下文,token 栈精确恢复 | system_actor.py | acdm-backend/app/storage/reversible/system_actor.py:23-38 |
business_transaction | 框一次原子业务操作,异常 saga 倒序 rollback | transaction.py | acdm-backend/app/storage/reversible/transaction.py:64-134 |
ReversibleStorageClient | 装饰 StorageClient,5 写方法改 async + 备份 + 限额 + 记 event | client.py | acdm-backend/app/storage/reversible/client.py:101-574 |
db_event_recorder | SQLAlchemy after_flush listener,ORM 变更抓 row 快照 | db_event_recorder.py | acdm-backend/app/storage/reversible/db_event_recorder.py:264-313 |
StorageEventsService | user_storage_events 表 append-only 写 + 查询 + 限额累计 | storage_events_service.py | acdm-backend/app/services/storage_events_service.py:42-237 |
RevertEngine | 按 operation_type 反向执行,ETag 乐观锁 | revert_engine.py | acdm-backend/app/services/revert_engine.py:43-613 |
StorageClient | 裸 Nextcloud WebDAV 客户端(PUT/DELETE/MOVE/PROPFIND) | nextcloud.py | acdm-backend/app/storage/nextcloud.py:59-101 |
ProjectStorage | 项目母目录操作的三层越界守护 facade | project_storage.py | acdm-backend/app/storage/project_storage.py:34-199 |
prune_acdm_trash / prune_db_soft_deleted | cron 清理备份/软删行,event 标 expired | scripts/ | 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,ActorMode 是 Literal["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):
| mode | user_id | agent_id | thread_id | 谁 set | Source |
|---|---|---|---|---|---|
direct | 必填(JWT sub) | 禁 | 禁 | FastAPI middleware set_direct_actor | acdm-backend/app/storage/reversible/actor.py:124-128 |
via_agent | 必填(thread owner) | 必填 | 必填 | MCP/langgraph 入口 set_via_agent_actor | acdm-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 的 _record 和 db_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_owner被create_project调用时,innerbusiness_transaction不创建新 txn,沿用 outer,inner 退出不触发 rollback(由 outermost 决定边界,transaction.py:92-100) - drain 的必要性:
db_event_recorder的on_commitlistener 是 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_type | Source |
|---|---|---|---|
upload_file | 覆盖前 COPY 旧文件 → .acdm-versions/<path>/<event_id>/ | upload_create / upload_overwrite | acdm-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_file | acdm-backend/app/storage/reversible/client.py:413-448 |
delete_dir | 删前 MOVE 整目录 → .acdm-trash/... | delete_dir | acdm-backend/app/storage/reversible/client.py:450-476 |
mkdir | 无(记 created/existed 区分) | dir_create | acdm-backend/app/storage/reversible/client.py:478-538 |
move_dir | 无(反向 MOVE 即可) | move | acdm-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_rollback 在 acdm-backend/app/storage/reversible/db_event_recorder.py:264-313。
| ORM 变更 | operation_type | pre_state | post_state | Source |
|---|---|---|---|---|
| INSERT | db_create | None | row snapshot | acdm-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 |
| 其他 UPDATE | db_update | 变更字段前值 | 变更字段后值 | acdm-backend/app/storage/reversible/db_event_recorder.py:197-232 |
| DELETE(hard) | db_hard_delete | row snapshot | None(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。
关键类:RevertEngine 在 acdm-backend/app/services/revert_engine.py:43-613,_dispatch 按 operation_type 路由(revert_engine.py:156-184)。
| operation_type | revert 逻辑 | Source |
|---|---|---|
upload_create | DELETE 该文件(校 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_create | created=True 才 RMDIR;existed=True no-op | acdm-backend/app/services/revert_engine.py:245-280 |
db_soft_delete | UPDATE ... SET deleted_at=NULL | acdm-backend/app/services/revert_engine.py:417-439 |
db_update | UPDATE ... SET <col>=pre_state.<col> | acdm-backend/app/services/revert_engine.py:441-485 |
db_create | DELETE FROM ... WHERE id=pk(graceful no-op) | acdm-backend/app/services/revert_engine.py:487-502 |
db_hard_delete | Phase 1 不支持,抛 50010 | acdm-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_transactionevent,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
_assert_name_valid:project_name 不得空 / 含/\/../ ASCII 控制字符 - 层 2:key 必须 == root 自身或以
{root}/起头 - 层 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() 只 flush 不 commit(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 | 产生方 | 备份位置 | 可 revert | revert 动作 | Source |
|---|---|---|---|---|---|
upload_create | RSC.upload_file(新建) | 无 | ✓ | DELETE 文件 | acdm-backend/app/services/revert_engine.py:188-194 |
upload_overwrite | RSC.upload_file(覆盖) | .acdm-versions | ✓ | COPY 回 | acdm-backend/app/services/revert_engine.py:196-208 |
delete_file | RSC.delete_file | .acdm-trash | ✓ | MOVE 回 | acdm-backend/app/services/revert_engine.py:210-218 |
delete_dir | RSC.delete_dir | .acdm-trash | ✓ | MOVE 回(目录) | acdm-backend/app/services/revert_engine.py:220-228 |
move | RSC.move_dir | 无 | ✓ | 反向 MOVE | acdm-backend/app/services/revert_engine.py:230-243 |
dir_create | RSC.mkdir | 无 | ✓ | created→RMDIR | acdm-backend/app/services/revert_engine.py:245-280 |
db_create | ORM INSERT | row snapshot | ✓ | DELETE 行 | acdm-backend/app/services/revert_engine.py:487-502 |
db_update | ORM UPDATE | pre snapshot | ✓ | 字段回写 | acdm-backend/app/services/revert_engine.py:441-485 |
db_soft_delete | ORM UPDATE deleted_at | 前态 | ✓ | deleted_at=NULL | acdm-backend/app/services/revert_engine.py:417-439 |
db_soft_undelete | revert 软删触发 | — | (审计) | — | acdm-backend/app/storage/reversible/db_event_recorder.py:108-109 |
db_hard_delete | ORM DELETE / cron prune | row snapshot | ✗ | Phase 1 不支持 | acdm-backend/app/services/revert_engine.py:179-183 |
revert | RevertEngine | — | ✗ | 不可 revert revert | acdm-backend/app/services/revert_engine.py:60-63 |
revert_transaction | RevertEngine 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-SQLDELETE/UPDATE不触发 listener(cron prune 因此手工调_record_db_event,见prune_db_soft_deleted.py:127-133)
Configuration
| Config | 默认 | 含义 | 影响 | Source |
|---|---|---|---|---|
ACDM_AI_NC_WRITES_ENABLED | True | kill switch:false=所有 via_agent 写被拒(40310) | 仅 via_agent;direct/system 不受影响 | acdm-backend/app/config.py:184-190 |
ACDM_AI_NC_DAILY_BYTES_CAP | 10 GB | via_agent 单日写入字节上限,超返 40312 | 仅 via_agent 累计 | acdm-backend/app/config.py:192-195 |
ACDM_AI_NC_BACKUP_THRESHOLD_MB | 50 | ≥ 此值只记 metadata 不复制文件体 | revert 大文件返 410 | acdm-backend/app/config.py:209-215 |
ACDM_REVERSIBLE_STORAGE_BYPASS | False | 应急 bypass:true=wrapper 退化为直调底层(无 event 无备份) | 全模式失去可逆性 | acdm-backend/app/config.py:217-223 |
ACDM_AI_NC_TRASH_RETENTION_DAYS | 90 | .acdm-trash/<date>/ 保留天数 | cron prune 读取,过期标 expired | acdm-backend/app/config.py:197-199 |
ACDM_DB_PRUNE_RETENTION_DAYS | 90 | 软删行保留天数,0=关闭 auto prune | cron 物理删 | acdm-backend/app/config.py:204-207 |
NEXTCLOUD_BASE_PATH | — | NC 挂载根(可含中文) | _ascii_url 须 percent-encode | acdm-backend/app/config.py:26 |
STORAGE_VERIFY_SSL | True | httpx 校验 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-defaultacdm-backend/app/storage/reversible/transaction.py:1-189— business_transaction + saga 倒序 rollback + drainacdm-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 快照 recorderacdm-backend/app/services/revert_engine.py:1-613— RevertEngine 按 operation_type 反向 + transaction 级 revertacdm-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 三层越界守护 facadeacdm-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 标 expiredacdm-backend/scripts/prune_db_soft_deleted.py:1-231— 软删行 cron 物理删 + fanout db_hard_deleteacdm-backend/app/config.py:182-223— kill switch / 配额 / bypass / 保留天数配置
Related Pages
| Page | Relationship |
|---|---|
| 项目管理与资源授权 | ProjectStorage 服务于该章项目母目录操作;permanent_delete 走本章 business_transaction |
| 鉴权与授权双层体系 | 本章 Actor 的 user_id 来自该章 JWT sub;via_agent 归属授权用户 |
| 审计、SSE 与后台任务 | user_storage_events 是该章活动页/审计的事件源 |
| 知识源 MCP 与 AgentToolsBFF | AI 写经 agent_tools_nc 入口 set_via_agent_actor 接入本章可逆链路 |
| acdm-backend 控制面架构 | 本章 set_direct_actor 由该章 HTTP middleware 全局 wire |
| CICD 与生产部署运维 | 本章 prune cron 与 kill switch env 由该章部署流程管理 |