Bug 排查參考文件 — Template-Edit + 新建專案(2026-05-21)

涵蓋本日兩個 bugfix session 所有處理的 14 個問題。 供日後遇到同類問題快速定位用。


§1

BUG 1:參與人員 Picker 選帳號沒反應

現象:開啟「新增參與人員」Dialog,下拉選帳號後,姓名欄沒帶入,form 無任何變化。

根因
PrimeVue 3.53 的 Dropdown 有一個已知 quirk:options 為空陣列時,@update:modelValue 不會觸發
pickerOptions 在 Dialog 開啟後才開始 lazy load(ensurePicker 呼叫在 startAdd 內),所以 Dialog 剛開時 options = [],此時 user 點選下拉雖然能看到選項(上次預載的 stale data),但不觸發 event。

修正:在 onMounted 就預載 picker options,確保 Dialog 開啟前 options 已有資料。

教訓:PrimeVue Dropdown 空 options 不觸發 event — 任何依賴 Dropdown event 的流程都要確保 options 在互動前已載入。


§2

BUG 2:Email 沒有自動帶入

現象:選了鉤稽帳號後,Email 欄仍為空。

根因
GET /users/menuUserMenuResponse schema 沒有 email 欄位,API response 沒回傳 email,FE 拿到 undefined

修正
BE api/auth/serializers/user.py

class UserMenuResponse(Schema):
    id = fields.Integer()         # ← 加
    uid = fields.String()
    login_name = fields.String()
    nickname = fields.String()
    email = fields.String(allow_none=True)  # ← 加

§3

BUG 3:單位 Dropdown 顯示 #99 而非名稱

現象:參與人員 Dialog 的「鉤稽單位」Dropdown 選項顯示 #99(index fallback)而非組織單位名稱。

根因:兩個疊加問題:

  1. OrgUnitMenuRoute@marshal_with(OrgUnitResponse(only=["uid","name"])) 排除了 id 欄位,FE 取 option.value = u.id 拿到 undefined
  2. Schema 加了 apply=False 但沒有顯式呼叫 .dump(),導致序列化根本沒執行

修正
api/auth/routes/org_unit_route.py

@marshal_with(OrgUnitResponse(only=["id", "uid", "name"], many=True), apply=False)
def get(self, ...):
    res = org_unit_service.get_org_units_menu()
    return return_response(True, OrgUnitResponse(only=["id", "uid", "name"], many=True).dump(res))

教訓apply=False 的 marshal_with 只當文件用,一定要手動 .dump()only 列表要確認 FE 需要的所有欄位都在。


§4

BUG 4:新增參與人員 500 錯誤(AttributeError: uid

現象:點「儲存」新增參與人員,BE 回 500,log 顯示 AttributeError: 'ResponsiblePartyEntity' object has no attribute 'uid'

根因
ResponsiblePartyEntity 是 link record(party_uuid + role_id + context_type + context_id 即為 natural key),沒有 uid 欄位
_link_to_module_frame 原本呼叫 self._resp_party.update(entity)BaseRepoImpl.update() 內部走 if entity.uid: ... 引發 AttributeError

修正:改為 idempotent return pattern:

existing = self._resp_party.get_all(ResponsiblePartyQueryEntity(...)) or []
if existing:
    return  # 已存在,skip
self._resp_party.add(ResponsiblePartyEntity(...))

教訓:CLAUDE.md 有標示「ResponsiblePartyEntity 是 link record,沒 uid 欄位,不能用 update()」。看到 ResponsiblePartyEntity 就要用 idempotent return,不要嘗試 update。


§5

BUG 5:資訊系統 Picker 值存 UUID 進整數 FK 欄位

現象:新增資訊系統時,picker 選了選項,儲存後 BE 報 400 或 IntegrityError(type mismatch)。

根因
InformationSystemMenuResponseSchemaid 欄位傳 UUID(為了 SystemPicker 元件向後相容),但 system_characteristic_id DB 欄位是整數型態。FE picker 設 optionValue="id" 拿到 UUID → 送給 BE 的是字串 UUID,不是 integer。

修正:Schema 加 pk 欄位:

class InformationSystemMenuResponseSchema(Schema):
    id = fields.Method("get_id")   # UUID — 給 SystemPicker 用
    pk = fields.Integer(attribute="id")  # integer PK — 給 system_characteristic_id 用

FE picker 改 optionValue="pk"

教訓:同一個 menu API 被兩個不同 consumer 用,需要回傳兩個 id(UUID 給 UI 辨識,integer PK 給 FK 欄位)。加 pk 欄位是標準解法,不要改掉 id(會破壞 SystemPicker)。


§6

BUG 6 / 7:Picker 無 loading 狀態 + 顯示已鉤稽選項

現象 6:Picker Dropdown 拉取選項期間 placeholder 沒有提示,用戶以為無資料。
現象 7:Dropdown 顯示已被其他列鉤稽的選項,選後造成重複鉤稽。

修正

  • 各 panel 加 *Loading ref,Dropdown 加 :placeholder="loading ? '選項載入中...' : '請選擇'":disabled="loading"
  • available*Options computed:過濾已使用的 id,編輯時保留自身的 id:
const availablePickerOptions = computed(() => {
    const usedIds = new Set(parties.value.map(p => p.party_uid).filter(Boolean))
    const currentId = editing.value?.party_uid
    return pickerOptions.value.filter(o => !usedIds.has(o.value) || o.value === currentId)
})

§7

BUG 8:設備 Panel Picker 空列表(page_size snake_case)

現象:template-edit 設備 Tab 點「新增設備」,picker 下拉清單為空。BE log:{'pager': {'pageSize': ['Unknown field.']}}

根因:FE 送 pager: { page: 1, pageSize: 1000 } 但 BE schema 用 page_size(snake_case)。

修正pageSizepage_size

教訓:BE pager schema 一律 snake_case。FE 發 API 時要對照 BE schema 名稱。


§8

BUG 9 / 10:新建專案設備/資訊系統不帶入 + 參與人員角色全是 viewer

現象 9:新建專案選資源庫後,受評範圍「資訊系統」「設備」沒有自動帶入。
現象 10:帶入的參與人員角色全是 viewer,不管資源庫設定的角色為何。

根因 9

  • ssp-resources API response 只回傳 matched_device_name / matched_info_system_name(顯示用),沒有 matched_device_uid / matched_info_system_uid(選取用)
  • ProjectCreateView 沒有呼叫 ssp-resources API

根因 10loadModuleFrameParties 硬寫 role: 'viewer',沒有讀資源庫的 role。

修正

  • BE 加 matched_device_uid / matched_info_system_uid 欄位(batch 查詢 UUID)
  • FE 加 loadModuleFrameSspResources() 函式,呼叫 API 後把 matched_*_uid merge 進 selectedSystemIds / selectedDevices
  • 修正 role: p.role || 'viewer'

§9

BUG 11 / 12:設備/資訊系統/單位鉤稽名稱顯示「未鉤稽」或 #id

現象 11:template-edit 設備/資訊系統 Tab 鉤稽欄一律顯示「未鉤稽」(Tag severity=warning),即使 Excel 匯入時已設定 device_id / system_characteristic_id
現象 12:template-edit 單位 Tab 鉤稽欄顯示 #id(數字),不顯示組織名稱。

根因 11
_batch_device_names 呼叫 self._device.get_by_id(did) — 但方法名應是 get_device_by_id(AttributeError,被 except 靜默吞)。
_batch_info_system_names 同理,InformationSystemDomainService 根本沒 get_by_id

修正 11

# Before(錯):
for did in device_ids:
    d = self._device.get_by_id(did)  # AttributeError!

# After(正):
devices = self._device.get_devices_by_ids(unique_ids)  # 1 次 batch query

根因 12
module_frame_party_service._enrich_user_org_namesOrgUnitQueryEntity(_in_id=list(org_ids)) — 但 OrgUnitQueryEntity.__init__ 沒有 _in_id 參數,TypeError 被 except Exception: pass 靜默吃掉。

修正 12:改用 self._org_unit.get_org_units_by_id_list(list(org_ids))

教訓:Domain service method 名稱要先 grep 確認(不要靠直覺猜),batch 查詢要用正確的 get_*_by_ids 方法。except Exception: pass 是 dangerous anti-pattern,只能在確定有 fallback 時用。


§10

BUG 13:新建專案 AO 實施狀態/現況說明不帶入

現象:新建專案選有 AO 資料的資源庫,AO 的「實施狀態」和「現況說明」在新專案中一律為空。

根因
module_frame_template_copy_service.py Step 2 的 AO UPDATE SQL:

JOIN oscal.catalog_control_assessments ccai
  ON ccai.uid::text = mfod.statement_identifier

此 JOIN 只能匹配 UUID 格式。但 SSP Excel import 和 docx import 透過 module_frame_write_strategy 寫入 AO defaults 時,statement_identifier 使用 (a)/(b) letter key(從 ccai.description 前綴 [a]/[b] 推導)。UUID JOIN 零命中 → objectives_updated = 0

修正:改用 CTE task_map 同時支援兩種格式:

WITH task_map AS (
    SELECT apt.*, ccai.uid::text AS ccai_uid, apc.control_id AS ctrl_id,
        CASE WHEN apt.title ~ '^\s*\[[a-zA-Z]\]'
             THEN '(' || lower(substring(apt.title FROM '^\s*\[([a-zA-Z])\]')) || ')'
             ELSE NULL END AS derived_letter_key
    FROM oscal.assessment_plan_tasks apt
    JOIN oscal.catalog_control_assessments ccai ON ccai.id = apt.catalog_control_assessment_id
    JOIN oscal.assessment_task_controls atc ON atc.task_id = apt.id
    JOIN oscal.assessment_plan_controls apc ON apc.id = atc.control_id
    WHERE apt.assessment_plan_id = :ap_id
)
-- JOIN with OR: UUID format OR letter key format
JOIN task_map tm ON (
    tm.ctrl_id = mfod.control_identifier AND (
        tm.ccai_uid = mfod.statement_identifier
        OR (tm.derived_letter_key IS NOT NULL AND tm.derived_letter_key = mfod.statement_identifier)
    )
)

教訓module_frame_control_objective_defaults.statement_identifier 有兩種格式(UUID from UI、letter key from import),任何 SQL 或查詢邏輯都需要雙格式支援。


§11

BUG 14:FE 手動存 AO 報驗證錯誤

現象:在 template-edit 控制項 Tab 編輯 AO 實施狀態後點儲存,BE 回 400 MODULE_FRAME_OBJECTIVE_NOT_VALID

根因
FE ModuleFrameTemplateEditViewaoLetterKey(a) 把 AO statement_identifier 轉成 (a)/(b) letter key,儲存時送給 BE。BE _validate_objective_in_profile 只驗 valid_assessment_uids(UUID 集合),(a) 不在集合 → 拋 BadRequestError。

修正:validation 也接受從 ccai.description 前綴推導的 letter key:

valid_letter_keys = set()
for a in assessments:
    m = re.compile(r'^\s*\[([a-z])\]', re.I).match(getattr(a, 'description', '') or '')
    if m:
        valid_letter_keys.add(f'({m.group(1).lower()})')

if statement_identifier not in valid_assessment_uids and statement_identifier not in valid_letter_keys:
    raise BadRequestError(ModuleFrameErrorCode.MODULE_FRAME_OBJECTIVE_NOT_VALID)

§12

共通 Pattern 與教訓

Pattern 說明
PrimeVue Dropdown 空 options 不觸發 event 所有依賴 Dropdown event 的流程,options 必須在互動前預載
apply=False marshal_with 需手動 .dump() Schema 裝飾器 apply=False 僅供文件,返回前要顯式 dump
ResponsiblePartyEntity = link record,無 uid 不能 update(),只能 idempotent add(查存在 → skip)
menu API 雙 id 格式 UUID(UI 辨識)+ integer(FK 欄位)可分 id / pk 回傳
statement_identifier 雙格式 UUID(UI save)or letter key(Excel/docx import),任何 JOIN/查詢要雙格式支援
Domain service method 名稱要 grep 確認 不要猜 get_by_id,先 grep 找到正確方法名
except Exception: pass 掩蓋 bug 靜默 catch 導致 silent failure,bug 表現為「沒資料」而非錯誤訊息