fix release rollback guards and sync api docs

This commit is contained in:
Chris
2026-03-23 22:56:19 +08:00
parent 752d7aba4d
commit 9a43563d45
5 changed files with 73 additions and 41 deletions

View File

@@ -2,7 +2,7 @@
## 現在位置 ## 現在位置
➡️ **Phase AE 主線完成2026-03-23** 系統已通過 Staging 真實環境驗證並可運作,尚有少量 hardeningrollback 邊界條件、自動化測試)持續補強。 ➡️ **Phase AE 主線完成2026-03-23** 系統已通過 Staging 真實環境驗證並可運作,尚有少量 hardening自動化測試、驗證流程一致化)持續補強。
## 本輪規格重置 ## 本輪規格重置

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from typing import Any from typing import Any
from fastapi import HTTPException
from app.domain.editor import VariantChange from app.domain.editor import VariantChange
from app.domain.editor_builder import build_runtime_payload_from_changes from app.domain.editor_builder import build_runtime_payload_from_changes
from app.domain.mappers import to_release from app.domain.mappers import to_release
@@ -25,6 +27,24 @@ class ReleaseService:
self.variant_repository = variant_repository or VariantRepository() self.variant_repository = variant_repository or VariantRepository()
self.change_repository = change_repository or VariantChangeRepository() self.change_repository = change_repository or VariantChangeRepository()
async def _enforce_single_published(
self,
experiment_id: str,
keep_release_id: str,
access_token: str | None = None,
) -> None:
published_items = await self.repository.list(
params={
"filter[experiment_id][_eq]": experiment_id,
"filter[status][_eq]": "published",
},
access_token=access_token,
)
for item in published_items:
item_id = str(item["id"])
if item_id != keep_release_id:
await self.repository.update(item_id, {"status": "draft"}, access_token=access_token)
async def list_releases( async def list_releases(
self, self,
experiment_id: str | None = None, experiment_id: str | None = None,
@@ -118,21 +138,14 @@ class ReleaseService:
experiment_id = str(target["experiment_id"]) experiment_id = str(target["experiment_id"])
# Demote any currently published release in this experiment to draft
currently_published = await self.repository.list(
params={
"filter[experiment_id][_eq]": experiment_id,
"filter[status][_eq]": "published",
},
access_token=access_token,
)
for other in currently_published:
if str(other["id"]) != release_id:
await self.repository.update(str(other["id"]), {"status": "draft"}, access_token=access_token)
item = await self.repository.update(release_id, {"status": "published"}, access_token=access_token) item = await self.repository.update(release_id, {"status": "published"}, access_token=access_token)
if not item: if not item:
return None return None
await self._enforce_single_published(
experiment_id=experiment_id,
keep_release_id=release_id,
access_token=access_token,
)
return ReleaseLifecycleResponse( return ReleaseLifecycleResponse(
id=str(item["id"]), id=str(item["id"]),
status=str(item["status"]), status=str(item["status"]),
@@ -150,46 +163,58 @@ class ReleaseService:
return None return None
experiment_id = str(target["experiment_id"]) experiment_id = str(target["experiment_id"])
target_status = str(target.get("status", ""))
if target_status != "published":
raise HTTPException(
status_code=409,
detail=f"Release '{release_id}' is not published; rollback is only allowed for published releases.",
)
current_version_no = int(target["version_no"]) current_version_no = int(target["version_no"])
# Demote the current release back to draft # Find the most recent non-archived release with an earlier version_no to restore.
await self.repository.update(release_id, {"status": "draft"}, access_token=access_token)
# Find the most recent draft release with an earlier version_no to restore
previous_candidates = await self.repository.list( previous_candidates = await self.repository.list(
params={ params={
"filter[experiment_id][_eq]": experiment_id, "filter[experiment_id][_eq]": experiment_id,
"filter[status][_eq]": "draft",
"filter[version_no][_lt]": str(current_version_no), "filter[version_no][_lt]": str(current_version_no),
"filter[status][_neq]": "archived",
"sort": "-version_no", "sort": "-version_no",
"limit": "1", "limit": "1",
}, },
access_token=access_token, access_token=access_token,
) )
if previous_candidates: if not previous_candidates:
raise HTTPException(
status_code=409,
detail=f"Release '{release_id}' cannot rollback because no earlier non-archived release exists.",
)
# Demote current published release, then promote previous release.
await self.repository.update(release_id, {"status": "draft"}, access_token=access_token)
restored_id = str(previous_candidates[0]["id"])
restored = await self.repository.update( restored = await self.repository.update(
str(previous_candidates[0]["id"]), restored_id,
{"status": "published"}, {"status": "published"},
access_token=access_token, access_token=access_token,
) )
if restored: if not restored:
raise HTTPException(
status_code=500,
detail="Rollback failed while promoting the target previous release.",
)
await self._enforce_single_published(
experiment_id=experiment_id,
keep_release_id=restored_id,
access_token=access_token,
)
return ReleaseLifecycleResponse( return ReleaseLifecycleResponse(
id=str(restored["id"]), id=str(restored["id"]),
status=str(restored["status"]), status=str(restored["status"]),
version_no=int(restored["version_no"]), version_no=int(restored["version_no"]),
) )
# No previous release to restore — return the now-drafted current release
updated = await self.repository.get(release_id, access_token=access_token)
if not updated:
return None
return ReleaseLifecycleResponse(
id=str(updated["id"]),
status=str(updated["status"]),
version_no=int(updated["version_no"]),
)
async def archive_release( async def archive_release(
self, self,
release_id: str, release_id: str,

View File

@@ -95,20 +95,20 @@
### G. Code Review 待修(✅ 完成2026-03-23 ### G. Code Review 待修(✅ 完成2026-03-23
- [x] `P0` Release lifecycle 修正:`publish()` 先 unpublish 同實驗其他 published release`rollback()` 設自身為 draft 並自動升版前一個 draft release - [x] `P0` Release lifecycle 修正:`publish()` 先 unpublish 同實驗其他 published release`rollback()` 會回退到較早版本
- [x] `P0` Editor preview API 契約對齊:前端 `buildPreview()` 改送 `items`(原為 `changes`),與 `BuildPreviewRequest.items` 一致。 - [x] `P0` Editor preview API 契約對齊:前端 `buildPreview()` 改送 `items`(原為 `changes`),與 `BuildPreviewRequest.items` 一致。
- [x] `P0` `save_changes()` 改為完整覆蓋語意:先刪除 DB 中不在 request items 的舊 change再 upsert。 - [x] `P0` `save_changes()` 改為完整覆蓋語意:先刪除 DB 中不在 request items 的舊 change再 upsert。
- [x] `P1` 移除 `variant_changes.enabled``EditorChangeRead` / `EditorChangeWrite` schema 刪除欄位frontend `editor-workspace-model.js` 同步清除。 - [x] `P1` 移除 `variant_changes.enabled``EditorChangeRead` / `EditorChangeWrite` schema 刪除欄位frontend `editor-workspace-model.js` 同步清除。
- [x] `P1` 建立 Experiment 時前端不再送 `experiment_key``ExperimentFormDialog` 移除 `autoFillKey``_auto_key``_generated_key`)。 - [x] `P1` 建立 Experiment 時前端不再送 `experiment_key``ExperimentFormDialog` 移除 `autoFillKey``_auto_key``_generated_key`)。
- [x] `P1` Runtime bootstrap 改為從 release snapshot 讀取 `traffic_weight` / `variant_key`,避免即時 variant 異動造成分流不一致。 - [x] `P1` Runtime bootstrap 改為從 release snapshot 讀取 `traffic_weight` / `variant_key`,避免即時 variant 異動造成分流不一致。
- [x] `P1` Editor bridge snippet `handleMessage``event.source === window.parent` 驗證;`EditorCanvasFrame` 捕捉 `event.origin` 並存入 `canvasOrigin`postMessage 改用具體 targetOriginfallback `"*"`)。 - [x] `P1` Editor bridge snippet `handleMessage``event.source === window.parent` 驗證;`EditorCanvasFrame` 捕捉 `event.origin` 並存入 `canvasOrigin`postMessage 改用具體 targetOriginfallback `"*"`)。
- [ ] `P1` Release rollback 邊界條件補強:限制只能 rollback `published` release並在 rollback 流程最後再次保證同一 experiment 僅有一個 `published` - [x] `P1` Release rollback 邊界條件補強:只允許 rollback `published` release並在 publish/rollback 保證同一 experiment 僅有一個 `published`
- [ ] `P2` 補自動化測試Backend API 契約、Editor flow、Runtime targeting/assignment、Release lifecycle 回歸。 - [ ] `P2` 補自動化測試Backend API 契約、Editor flow、Runtime targeting/assignment、Release lifecycle 回歸。
- [ ] `P2` 文件狀態一致化:`ROADMAP``CURRENT_WORK_ITEMS``VALIDATION_EXECUTION_LOG` 的完成狀態同步。 - [ ] `P2` 文件狀態一致化:`ROADMAP``CURRENT_WORK_ITEMS``VALIDATION_EXECUTION_LOG` 的完成狀態同步。
## 系統狀態 ## 系統狀態
**Phase AG 主線已完成2026-03-23目前剩餘1 項 P1 邊界條件 + 2 項 P2 強化。** **Phase AG 主線已完成2026-03-23目前剩餘2 項 P2 強化。**
目前系統可運作P1/P2 完成後可進一步提高一致性與可回歸性。 目前系統可運作P1/P2 完成後可進一步提高一致性與可回歸性。

View File

@@ -15,6 +15,7 @@
- `variant_changes` 移除 `enabled` 欄位(每筆 change 預設啟用)。 - `variant_changes` 移除 `enabled` 欄位(每筆 change 預設啟用)。
- 建立 Experiment API 成功時,需同步建立一個原始版本 Variant系統管理 - 建立 Experiment API 成功時,需同步建立一個原始版本 Variant系統管理
- Runtime 實驗匹配需基於 URL 規則與裝置限制。 - Runtime 實驗匹配需基於 URL 規則與裝置限制。
- Release rollback 僅允許針對 `published` 版本,且操作後需維持「每個 experiment 僅一個 published」。
## 分區 ## 分區

View File

@@ -201,8 +201,9 @@
- Path 必填:`release_id` - Path 必填:`release_id`
- 業務行為: - 業務行為:
- 目標 release 先降為 `draft` - 只允許 rollback 目前 `published` 的 release否則 `409`
- 嘗試把同 experiment版本號較小、最新的一筆 `draft` 升為 `published` - 會找同 experiment 中「版本號較小且非 archived」的最新 release 升為 `published`
- rollback 完成後,會強制同 experiment 僅保留一個 `published`
### `POST /api/admin/releases/{release_id}/archive` ### `POST /api/admin/releases/{release_id}/archive`
@@ -379,6 +380,11 @@
1. 同一 experiment 下,所有 variant 的 `traffic_weight` 加總不等於 `100` 1. 同一 experiment 下,所有 variant 的 `traffic_weight` 加總不等於 `100`
### `rollback` 回 `409` 常見原因
1. 你指定的 release 目前不是 `published`
2. 該 release 沒有更早的非 archived 版本可回退。
### `editor PUT changes` 看起來「少資料」的常見原因 ### `editor PUT changes` 看起來「少資料」的常見原因
1. 你沒有把舊 change id 一起送回去,會被 full-replace 刪掉。 1. 你沒有把舊 change id 一起送回去,會被 full-replace 刪掉。