FR-080 批次 A 盤點:system-infra 候選七支

盤點日期 2026-09-09。證據路徑:monorepo 根 ~/Projects/Jedicogy/module/jedi-python-package/(記為 <mono>)、 主專案根 ~/Projects/Billows/Audit-Manager/compliance-manager-be/(記為 <be>)。 DB 證據來自 DEV(192.168.50.188:25432 / guidant_ai_dev),全部唯讀 SELECT。

§1

規模速覽(先給尺度,後面逐支展開)

套件 import 名 版本 套件源碼行數 自有表 route migrations harness
jedi-common jedi_common 0.0.34 3,453 1(system_logs) 0 0 無
jedi-iam jedi_iam 0.1.0 15,098 16 39 0 有
jedi-system-config jedi_system_config 0.0.16 921 1 0 0 有
jedi-system-menu jedi_system_menu 0.0.13 1,199 1 5 0 無
jedi-notification jedi_notification 0.0.11 774 0 1 0 有
jedi-log jedi_api_log 0.0.14 1,305 1 2 0 無
jedi-log-forwarding jedi_log_forwarding 0.0.1 2,065 1 2 2 有

行數證據:find <pkg>/<import名> -name '*.py' | xargs wc -l(2026-09-09 實跑)。 版本證據:各 pyproject.toml:3(log-forwarding 在 :3,iam 在 :3)。


§2

jedi-common

Q1 它是什麼

「所有 jedi 套件與宿主共用的地基」——實際做四件事:① DB session 與交易(session_scope / @transaction / RLS session 變數注入 / BaseRepositoryImpl 查詢基底);② 例外類與 Flask error handler 註冊; ③ logging 設定(含把系統 log 寫進 DB 的 system_logs);④ 一組共用的 DTO / entity / schema 骨架 與身分名冊 port。

README 與程式碼不一致,且落差很大:<mono>/jedi-common/README.md:26-50 的目錄樹寫的是 jedi_handler / jedi_logger / jedi_session / jedi_utils 四個頂層包——這些名字在源碼中 一個都不存在(實際是 jedi_common/handler/、jedi_common/logger/、jedi_common/session/、 jedi_common/utils/)。README 也完全沒提 identity/(身分名冊 port 三件套)、interfaces/ (共用 DTO/entity/spec)、enums/(error code / schema code)這三個現役子包,更沒提它自帶一張 DB 表。README 沒有任何接入指引段落(grep -qi "register(|接進你的產品|quickstart" 無命中), 54 行裡有 24 行是一張過時的相依表(列了 pytest / testcontainers 為 runtime,但 pyproject.toml:28-36 已把它們搬進 dev group)。

Q2 資料

自有表 1 張:

表 存什麼 關鍵欄位 證據
system_logs 系統日誌——由 logging handler 自動寫入的每一條 app/api/domain/infra 層 log act_time / level / event_code / message / user_uid / path+func_name+line_no <mono>/jedi-common/jedi_common/logger/db_log/system_log/infra/models/system_log.py:6

live 實況:DEV system_logs 有 663,249 列,是 RANGE(act_time) 月分區表(5 個分區), BRIN 索引,無任何 FK、無 tenant_id(psql \d public.system_logs 2026-09-09)。 主專案的分區化 migration 在 <be>/scripts/sql/2026-06-03-log-tables-partitioning.sql:73-118。

對別人的表的參照:

  • (a) 真 FK:0 條。 全套件唯一的 ForeignKey 宣告在 <mono>/jedi-common/jedi_common/session/database/model/tenant_mixin_model.py:14,22 ——tenant_id → tenants.id(NOT NULL)與 org_unit_id → org_units.id(nullable)。 但這不是 jedi-common 自己的表在參照,是 mixin 的定義:任何 class X(BaseModel, TenantScopedMixinModel) 的表才會長出這兩欄。system_logs 自己沒有繼承這個 mixin(system_log.py:5 是 class SystemLog(Base))。
  • (b) ORM relationship 跨包:0 條(全套件 grep relationship( 只在 base_repository_impl.py 出現字串,非宣告)。
  • (c) 軟參照:system_logs.user_uid(varchar 36)存的是 users.uid,由 <mono>/jedi-common/jedi_common/logger/custom_formatter.py:48 從 user context 取 user_context.uid 填入, 不建 FK。同理 user_name 存 nickname(custom_formatter.py:49)。
  • (d) raw SQL 直打別人的表:0 條。 session_scope() 有大量 raw SQL,但全是 SET LOCAL app.* 的 session 變數設定,不碰任何表(<mono>/jedi-common/jedi_common/session/database/db.py:93-118)。

🔴 決策者特別指定要查的「system_logs 對 tenants 的 FK」——實查結論:不存在,兩層都不存在。 ① ORM 層:system_log.py 沒繼承 TenantScopedMixinModel,12 個欄位裡沒有 tenant_id。 ② DB 層:\d public.system_logs 顯示的索引只有 pk_system_logs_p 與 BRIN,沒有任何 FK 約束、也沒有 tenant_id 欄位。 所以 jedi-common 的表對 jedi-iam 的表零耦合。真正產生 → tenants.id FK 的是那支 mixin, 而 mixin 的消費者是別人:monorepo 內 14 支套件(bulletin / compliance-audit / detection ×6 / device / flow-engine ×3 / information-system / license-runtime / remote-agent ×3 / survey ×2 / system-config / task-platform)+ 主專案 6 個 model(<be>/infra/feedback/model/feedback_issue.py:12、 <be>/infra/oscal/model/{framework,ssp_docx,ssp_excel}_parse_job.py、<be>/infra/module_frame/models/module_frame.py:11、 <be>/infra/bulletin/models/bulletin.py:29)。DEV live 實查 → tenants 15 條 FK、→ org_units 23 條 FK, 全部來自這些消費者的表,沒有一條來自 jedi-common 自己。

表名產品詞彙:無。system_logs 是通用詞。

Q3 Port

  • 需要宿主提供的 port:0 張(本套件是地基,沒有 register()、沒有 adapters dataclass)。 唯一「宿主要給」的東西是環境變數:ENABLE_MULTI_TENANT(tenant_mixin_model.py:13,21, 必須在 import model 之前設好,因為 @declared_attr 在 class 定義當下決定要不要產生欄位)、 RUN_ENV(config_logger.py:44)、DEFAULT_SCHEMA(declarative_base.py:5)、TZ(db_mw.py:11)。 這四支是本套件僅有的 os.getenv,也是它與「不讀環境變數」這條 D16 通則的差異點——地基層被允許。
  • 提供給別人的能力入口(實測主專案 import 分佈,grep -rhn "from jedi_common\.[a-z_.]*" 去重統計):
模組 主專案引用處數 給什麼
jedi_common.handler.exception 184 NotFound / ConflictError / BadRequestError 等 6 個例外類
jedi_common.session.database.db 97 @transaction / session_scope / init_db
jedi_common.session.auth.auth_context 87 get_user_context / set_user_context
jedi_common.utils.response_util 78 return_response(全站回應信封)
jedi_common.session.database.repository.base_repository_impl 20 BaseRepositoryImpl
jedi_common.session.database.model.base_model 20 BaseModel
jedi_common.enums.schema_code 19 AUTH_PARAMS(Swagger 參數)
jedi_common.session.database.model.tenant_mixin_model 7 TenantScopedMixinModel
jedi_common.utils.audit_log 7 audit() 稽核事件格式
jedi_common.logger.db_log.db_handler 3 DBLogHandler
  • 它自己提供、給套件生態用的 port(新標準件):jedi_common.identity 的 IUserNameResolver / ITenantResolver / IOrgUnitResolver + 聚合包 IdentityContext (<mono>/jedi-common/jedi_common/identity/resolvers.py:63,83,92、context.py:60)。 但目前只有 1 個消費者:jedi-file-upload(<mono>/jedi-file-upload/jedi_file_upload/plugin.py:129,140)。
  • 宣告了但零使用的 port:無(identity 三件套雖只有一個消費者,但那個消費者是真的在用)。

Q4 消費者

  • (b) 其他 jedi- 套件:23 支全部 import 它*(grep 統計,排除 tests)—— jedi_iam 183 處、jedi_oscal_v2 144、jedi_survey 115、jedi_compliance_audit 114、jedi_detection 101、 jedi_participant 52、jedi_task_platform 46、jedi_flow_engine 40、jedi_issue 27、jedi_license_runtime 22、 jedi_remote_agent 21、jedi_file_upload 16、jedi_ai_dashboard 16、jedi_system_menu 15、 jedi_information_system 14、jedi_evidence_classification 13、jedi_device 12、jedi_api_log 11、 jedi_bulletin 10、jedi_system_config 9、jedi_log_forwarding 8、jedi_notification 3、jedi_integrity 2。
  • (a) 主專案:全 repo 幾乎每個目錄都 import(上表 700+ 處)。
  • 方向:所有人依賴 jedi-common,jedi-common 依賴任何人=0。

Q5 執行期形狀

  • route:0 條(無 api 層、無 blueprint)。
  • 背景執行緒/排程:套件自己不起,但它的 dictConfig 掛的 DBLogHandler 會在每一次 log 呼叫時 同步寫 DB(<mono>/jedi-common/jedi_common/logger/db_log/db_handler.py:30 → system_log_service.add_system_log()), 而 system_log_service 是模組層單例(system_log_service.py:44)。
  • 對外 I/O:有,而且是 import 期副作用 —— <mono>/jedi-common/jedi_common/logger/custom_formatter.py:22-40 在 模組 import 當下無條件設定全域 OTel TracerProvider / MeterProvider, 並建立指向 localhost:4317 的 OTLP gRPC exporter(:27 OTLPSpanExporter(endpoint="localhost:4317")、 :35 OTLPMetricExporter(endpoint="localhost:4317")),還呼叫 LoggingInstrumentor().instrument()。 這不是 lazy、不是可關閉的——只要 logging config 被載入就會發生。檔案系統 I/O: config_logger.py:75 os.makedirs("log") + config_dev.py:37 寫 log/app.log。
  • 快取 / Redis:<mono>/jedi-common/jedi_common/session/redis/redis.py 只有兩行—— redis_client = FlaskRedis(),一個未初始化的 extension 物件,套件自己不用它。
  • 能不能單獨起成 process:不能,也不該。 它沒有 route、沒有 register()、 沒有 main,是純 library。

Q6 通用性

(a) 能直接用。工單系統要的 session / 例外 / 回應信封 / BaseRepository 全部通用, 且 db.py:15-37 的 _is_postgresql() 已明確為非 PostgreSQL(SQLite)留了逃生門 (註解點名 evidence-agent 這個 consumer)。

(b) 寫死 Guidant 產品知識的地方:

位置 寫死了什麼 影響
<mono>/jedi-common/jedi_common/logger/config_dev.py:53-95 logger 名單硬編為 api / app / infra / domain / common / middleware / error_handler / tests 八個 這是 jedi 生態的分層命名,不是 Guidant 專屬,但任何不照這套命名的 consumer,log 一律不落 DB 也不進檔(propagate: False)。jedi-log-forwarding 就因此被迫要求宿主傳 ops_logger_name="common.log_forwarding"(見該支 Q6)
<mono>/jedi-common/jedi_common/logger/custom_formatter.py:27,35 OTLP endpoint 硬編 localhost:4317 無法設定,且無論宿主要不要 OTel 都會起
<mono>/jedi-common/jedi_common/utils/gen_comment.py:8 OPENAI_API_KEY = os.getenv(...) + runtime 依賴 openai>=2.8.0(pyproject.toml:25) 一個「產生註解」的開發輔助工具,讓每一個 consumer 都被迫安裝 openai SDK。與稽核無關的工單系統同樣被迫裝
<mono>/jedi-common/jedi_common/utils/audit_log.py audit() 產出 [AUDIT:<name>] k=v 格式並寫進 system_logs.message 格式本身通用(event_code 由呼叫端定義,檔頭 :11 明說不預設任何碼表),但函式名與檔名帶「audit / 稽核」字樣,是本套件唯一的產品詞彙命中
db.py:102 is_super = (not user_ctx.tenant_id) or _session_paths_have_root(...) 「最上層租戶成員=平台管理者」是 Guidant 的租戶模型判定,寫死在地基的 session 開啟路徑上

Q7 插件完整度

項目 有/無 證據
① api 層隨包 無 無 api/ 目錄
② register() 註冊入口 無 無 plugin.py(find 只命中 .venv 內 coverage 套件的同名檔)
③ migrations 隨包 無 0 支 .sql。system_logs 的建表與分區在主專案 <be>/scripts/sql/
④ DI container 預設 無 無 container 檔
⑤ 獨立 harness 無 無 harness/
⑥ 接入 README 無 README 54 行且與源碼結構不符(見 Q1)

它是七支裡唯一一支「連 D6 插件契約的殼都沒有」的——這與它的定位一致(地基不是插件), 但也意味著它沒有任何「拔掉就消失」的可驗證邊界。

Q8 糾纏對象

  • 與 jedi-log(jedi_api_log):曾經是同一件事的兩半,已被切開。 <mono>/jedi-log/pyproject.toml:54-60 記載:jedi-log 原本另 ship 一個 jedi_system_log import 名, 與 jedi-common 宣告同一張 system_logs 表,在 jedi-common 已載入時 import 即拋 InvalidRequestError: Table 'system_logs' is already defined;且該表 61 萬列全部由 jedi-common 的 DBLogHandler 寫入,jedi_system_log 寫過 0 列、model 漂移成 9 欄。 FR-069 P3.6(CM-1472)整包刪除。現況兩支表已分家(system_logs 在 jedi-common、 api_logs 在 jedi-log),兩表之間無 FK、無 relationship,但同一次 HTTP 請求會同時寫兩張表 ——api_logs 由 <be>/common/middleware/app_mw.py:41,52 的 before/after_request 寫, system_logs 由同一請求內任何 logger 呼叫寫。兩者在 DEV 都做了月分區、同一支 migration 處理(<be>/scripts/sql/2026-06-03-log-tables-partitioning.sql:2 檔頭寫「api_logs / system_logs 改 RANGE(act_time) 月分區」),且 retention 天數設在同一張 VALUES 表(:136 ('api_logs', 90), ('system_logs', 180))。
  • 與 jedi-log-forwarding:system_logs 的內容與轉發鏈是同一條 logging 鏈的兩端。 jedi-common 的 dictConfig 把 DBLogHandler 掛上 7 個 logger,jedi-log-forwarding 把 QueueHandler 掛上同一批 logger;<be>/core/app_factory.py:67-69 明文標記位置紅線 ——configure_logging() 必須在 start_forwarding_chain() 之前,因為 dictConfig 會整批替換 handlers 清單、把轉發鏈的 QueueHandler 丟掉。一邊的行為直接決定另一邊能不能運作。
  • 與 jedi-iam:透過 TenantScopedMixinModel 單向依賴——jedi-common 定義 FK 指向 jedi-iam 的表。 tenant_mixin_model.py:14,22 硬編字串 "tenants.id" / "org_units.id",而這兩張表的 ORM 定義在 <mono>/jedi-iam/jedi_iam/infra/models/{tenant,org_unit}.py:15。後果寫在 <mono>/jedi-system-config/README.md「兩道宿主前提」:consumer 必須讓 tenants / org_units 兩張表在 SQLAlchemy metadata 裡,否則 mapper 設定期就炸 NoReferencedTableError。 也就是說任何用 mixin 的套件都被迫把 jedi-iam 一起裝上(或自己偽造這兩張表, jedi-license-runtime / jedi-remote-agent 的 harness 就是這樣做的:<mono>/jedi-license-runtime/harness/dev_app.py:195 註解明說)。
  • 看起來像但不是一件事:jedi_common.session.redis 與 jedi-iam 的 redis 使用。 前者只是一個 FlaskRedis() 空殼(2 行),後者自己有 jedi_iam/common/utils/redis_client_util.py 獨立實作 OTP 節流。兩者沒有共用任何連線或 key 命名,不是同一件事。

§3

jedi-iam

Q1 它是什麼

管「人的身分」:帳號、租戶、組織單位、角色與能力點(RBAC)、登入(密碼 / LDAP / Google)、 二階驗證(TOTP / Email OTP)、人機驗證(Cloudflare Turnstile),以及授權守門的主體域 (platform-admin / super-admin / capability / signed-token)。

README 與程式碼一致,且是七支裡寫得最準的一份。<mono>/jedi-iam/README.md:21-33 的疆界表 逐條可驗:說「授權守門主體域在疆界內、資源域留宿主」→ 實查 <mono>/jedi-iam/jedi_iam/authz/ 確有 platform.py / admin.py / capability.py / signed_token.py / decorators.py 五檔, 而資源域(project / ssp / workflow)確實只在主專案 <be>/common/authz/ 有實作。 說「39 條身分 API 已上移」→ grep -c "api.add_resource" <mono>/jedi-iam/jedi_iam/api/__init__.py = 39,數字吻合。 說「OTP 信的文案與寄送走 port 由宿主注入」→ <mono>/jedi-iam/pyproject.toml:51-53 明文記載 「jedi-mfa 原本相依 jedi-notification,本棒刻意斷掉」,且實查 jedi_iam/ 全目錄 零處 import jedi_notification。

Q2 資料

自有表 16 張(15 張主體 + 1 張 mfa):

表 存什麼 關鍵欄位 證據(<mono>/jedi-iam/jedi_iam/infra/models/)
users 帳號 login_name / nickname / uid / tenant_id / org_unit_id user.py:23
tenants 租戶(自參照樹) parent_id / path tenant.py:15
org_units 組織單位(樹,掛租戶) tenant_id / parent_id org_unit.py:15
roles 角色 tenant_id(可空=全域角色) role.py:23
capabilities 能力點字典 name / resource_type / action / is_platform capability.py:15
role_capabilities 角色↔︎能力點 複合 PK role_capability.py:14
user_roles 使用者↔︎角色(帶授權範圍) user_id / role_id / tenant_id / org_unit_id user_role.py:20
user_tenants / user_org_units 使用者↔︎租戶 / 單位 複合 PK user_tenant.py:14 / user_org_unit.py:14
ui_routes 前端路由樹 parent_id ui_route.py:16
route_capabilities 路由↔︎能力點 複合 PK route_capability.py:18
user_auth_providers 帳號的認證來源(LDAP/Google) user_id / provider user_auth_provider.py:16
login_tokens 登入 token — login_token.py:12
login_logs 登入紀錄(已死鏈,見下) — login_log.py:21
user_change_pwd_logs / user_change_password_request 改密紀錄 / 請求 user_id user_change_password_log.py:12 / ..._request.py:13
totp_secrets TOTP 種子 — mfa/infra/totp/models/totp_secret.py:8

⚠️ login_logs 是已標記的死鏈:四個檔案(model / mapper / repo / dto)的檔頭都寫同一句 「登入稽核實際走的是 EventCode.USER_LOGIN_SUCCESS(主專案 logger extra),與本鏈無關」 (infra/models/login_log.py:5、infra/mapper/login_log_mapper.py:5、 infra/repository/login_log_repo_impl.py:5、app/dto/login_log.py:5)。

對別人的表的參照:(a)(b)(c)(d) 全部 0 條。 全套件的 FK 與 relationship(grep ForeignKey|relationship( 命中 60+ 行)全部指向自己的 16 張表,沒有一條指向套件外。這是七支裡唯一做到「資料完全自足」的。

它是被別人參照的那一端:DEV live 實查入向 FK 統計—— org_units 被 23 條 FK 指、tenants 15 條、users 7 條、roles 4 條、ui_routes 1 條。 指過來的表分佈在 bulletin / compliance(information_systems / job_executions / workflow_*)/ config(tenant_licenses)/ devices / feedback_issues / oscal(4 支 parse_job)/ survey 等, 橫跨整個產品。

表名產品詞彙:無。16 張表全是通用身分詞彙。

Q3 Port

需要宿主提供的 port(<mono>/jedi-iam/jedi_iam/plugin.py):

port 簽名 用在哪 套件內使用處數
INotifier send(*, to, subject, body) -> None OTP 信、帳號建立通知信 10
ISettingsReader get(key, default=None) -> Any 全套件取設定值(redis 連線 / SYSTEM_NAME / Turnstile secret) 8
IOtpMessageBuilder build(*, otp_code, ttl_minutes) -> tuple OTP 信文案(i18n) 11
IAccountMessageBuilder build(*, login_name, password) -> tuple 批次匯入建帳號的通知信文案 6
ISubTenantQuota assert_can_add_sub_tenant() -> None 建子租戶前的額度檢查 2(ports/tenant_provisioning.py:36、app/service/tenant_provisioning_service.py:108-109)
ILicensedModules licensed_resource_types() -> Optional[frozenset] 建租戶時過濾未授權模組的能力點 3(同上 :123-133)
ITenantStorageSeeder seed_from_root(tenant_id) -> None 新租戶複製 ROOT 的儲存設定 1(同上 :98-100)
IAuthenticateProvider(ABC) authenticate / test_connect / verify_user 認證來源抽象(password / ldap / google 三個內建 adapter 實作它) ports/auth_provider/auth_provider.py:7
IMFAProvider(ABC) verify(user_id, code) -> bool MFA 管道抽象 mfa/ports/mfa_provider/mfa_provider.py:7

route 層另外要四樣(plugin.py:238-253):services(21 個 service provider 名冊, IdentityServices 於 :147-168)、auth_required、capability_required、refresh_auth_required、 response_builder、empty_data_error_factory;以及 4 個產品 hook(IdentityHooks,:188-191)。 🔴 認證欄位刻意無預設,缺就 _assert_api_wiring() 拒絕掛載(plugin.py:382)。

提供給別人的能力入口:jedi_iam.authz(守門 decorator,7 處主專案引用+jedi-survey 6 處)、 jedi_iam.domain.entities.user_query_entity(19 處)、jedi_iam.domain.service.user_domain_service(12 處)、 jedi_iam.infra.models.user(9 處)、jedi_iam.plugin(6 處)、jedi_iam.middleware.socketio_auth、 jedi_iam.security_policy。

宣告了但零使用的 port:無(三張「看起來可能是樣板」的 tenant_provisioning port 實查都有呼叫點)。

Q4 消費者

  • (a) 主專案:269 處引用(排除 test),橫跨 40+ 個目錄——auth(10) / module_frame(8) / flow_control(7) / oscal(6) / bulletin(6) / authz(6) / associations(4) / user_auth_provider(3), 以及 core/app_factory.py、core/iam_wiring.py、common/iam_ports.py、common/middleware/jwt_mw.py 等接線點。 <be>/common/authz/ 的五個主體域檔案已是 re-export shim——例如 <be>/common/authz/platform.py:1-10 整檔就是「[deprecated shim] 已上移至 jedi_iam.authz.platform」 + 一行 re-export;capability.py:1-15 同形。
  • (b) 其他 jedi- 套件:3 支,共 14 處*—— jedi-survey 7 處(api/routes/survey_*.py 各 from jedi_iam.authz import require_capability、 app/handler/fill_survey_socketio_handler.py:8 用 socketio_auth.AuthenticatedNamespace、 app/service/survey_discussion_service.py:1 用 UserDomainService)、 jedi-compliance-audit 5 處(review_service.py:3 / audit_round_app_service.py:25 / poam_service.py:9,10 / poam_app_service.py:219)、 jedi-detection 2 處(detection_profile_service.py:50 / detection_orchestration_service.py:26)。 方向:這 3 支依賴 jedi-iam。
  • jedi-iam 自己依賴:只有 jedi-common(pyproject.toml:18),零依賴其他 jedi- 業務套件*。

Q5 執行期形狀

  • route:39 條,blueprint 名 iam、prefix /api/1.0(plugin.py:47,55)。 涵蓋 /login /logout /refresh /users /user/<uid> /roles /tenants /org-units /capabilities/menu /ui-routes /otp-* /totp-* /forget-password 等 (<mono>/jedi-iam/jedi_iam/api/__init__.py:165-220)。
  • 背景排程 / 長駐 thread:套件自己沒有。但宿主的 INotifier 實作會另開 thread (<be>/common/iam_ports.py:59-62 threading.Thread(...).start()),那是宿主的選擇。
  • 對外 I/O:四種 ——① LDAP(infra/adapter/authenticate_adapter/ldap/ldap_adapter.py:7,105,125, ldap3.Connection.open());② 對外 HTTP(turnstile/verifier.py:15,87, POST https://challenges.cloudflare.com/turnstile/v0/siteverify,timeout 有設); ③ Google OAuth(google_auth/google_auth_adapter.py);④ 檔案系統 (Excel 批次匯入上傳目錄,plugin.py:207 excel_upload_dir: str = "file/user/upload/")。 寄信不是它做的——走 INotifier port 交給宿主。
  • 快取 / Redis:有,自己的一份。jedi_iam/common/utils/redis_client_util.py 的 RedisClient 被 OTP 節流與 TOTP 失敗計數使用(mfa/app/service/email_service.py:61,78,80,90,101、 totp_service.py:40,43,52,56、mfa/infra/email/adapter/email_adapter.py:11-15)。 redis==5.2.1 明列在 runtime 相依(pyproject.toml:44)。
  • 另有 繞 RLS 的提權唯讀 session(infra/elevated_session.py),用於「這個帳號是不是受保護 root admin」 這類系統層事實查詢,檔頭 :1-33 有 DEV 實測數據佐證(子租戶 session 查得 0 列、提權查得 1 列)。
  • 能不能單獨起成 process:能。 harness 已實走驗證——<mono>/jedi-iam/README.md:44-51 記錄 2026-08-30 實測:空庫建出 16 張表、register() 掛 blueprint、/healthz 回 200、 全程未 import 主專案任何模組,353 個測試 pass。

Q6 通用性

(a) 能直接用,是七支裡插件化最完整的。工單系統要的帳號/角色/RBAC/登入全都通用。

(b) 寫死 Guidant 產品知識的地方——很少,而且都在註解或預設值層,不是邏輯層:

位置 寫死了什麼 性質
plugin.py:207 excel_upload_dir: str = "file/user/upload/" 預設值,IdentityConfig 可覆寫(主專案就覆寫成 app.config["USER_EXCEL_UPLOAD_DIR"],<be>/core/app_factory.py:309)
plugin.py:47,55 DEFAULT_URL_PREFIX = "/api/1.0" / DEFAULT_BLUEPRINT_NAME = "iam" 預設值,可覆寫
turnstile/verifier.py:15 Cloudflare siteverify URL 硬編 是 Cloudflare 的固定端點,非產品知識
plugin.py:125,138,175,246,330 註解裡點名「Guidant AI」 只是註解,不影響行為
authz/error_code.py 四個守門 error code 的字串值凍結為 GRC_* 這一條是真的產品洩漏——GRC 是 Guidant 的前綴。凍結理由是 FE i18n key 相容(見 <be>/CLAUDE.md 記載),但對新產品而言就是一組帶別的產品前綴的錯誤碼

security_policy.py:31 特別做了「build_defaults 收 env_reader 參數讓宿主餵,套件自己不碰 os.getenv」, 實查 jedi_iam/ 零處實際 os.getenv 呼叫(grep 命中的 5 行全是註解/docstring)。

Q7 插件完整度

項目 有/無 證據
① api 層隨包 有 jedi_iam/api/,39 條 route
② register() 有 register(app, adapters=None, config=None, schema_extensions=None, mount_api=True) -> PluginHandle(plugin.py:402-407);另有 create_blueprint()(:352)
③ migrations 隨包 無 0 支 .sql。16 張表的建表全在主專案 <be>/scripts/sql/
④ DI container 預設 無 套件不含 container;宿主用 IdentityServices 名冊注入 21 個 provider
⑤ 獨立 harness 有 harness/dev_app.py + harness/docker-compose.yml(port 5499)
⑥ 接入 README 有 244 行,含 quickstart 與實走結果

五件套做到 4/5,缺的是 migrations。 這是它與 jedi-log-forwarding 的唯一差距。

Q8 糾纏對象

  • 與 jedi-notification:pyproject 宣告與 port 形式的分界,決策者指定要查——實查結論如下。 ① pyproject 層:無依賴。 <mono>/jedi-iam/pyproject.toml 的 dependencies 清單(:10-54) 沒有 jedi-notification,且 :51-53 有明文註記:「⚠️ 注意:jedi-mfa 原本相依 jedi-notification, 本棒刻意斷掉——OTP 信的文案與寄送改走 INotifier / IOtpMessageBuilder 兩張 port 由宿主注入(D16)。 這條邊斷掉是本卡的目的之一,日後不要因為『寄信方便』把它加回來。」 ② 源碼層:零 import。 grep -rn "jedi_notification" <mono>/jedi-iam/jedi_iam/ = 0 命中。 ③ 但執行期供應鏈是實際接通的:<mono>/jedi-notification/README.md:40-45 自己寫明 「jedi-iam 定了一張 INotifier port,宿主的實作(common/iam_ports.py::NotificationServiceNotifier) 背後就是這支套件。也就是說 jedi-iam → 宿主 adapter → jedi-notification 是一條實際在跑的供應鏈。」 主專案端證據:<be>/common/iam_ports.py:46-62 的 NotificationServiceNotifier 收 notification_service 並在 send() 內另開 thread 呼叫;接線在 <be>/core/app_factory.py:301-311,把 container.notification_container.notification_service() 餵進 build_identity_adapters()。所以是「靜態解耦、動態耦合」——套件互不認識,宿主當中介。
  • 與 jedi-common:單向依賴 + 被 mixin 反向指名。 jedi-iam import jedi-common 183 處(最大戶); 反過來 jedi-common 的 tenant_mixin_model.py:14,22 硬編 "tenants.id" / "org_units.id" ——那是 jedi-iam 的表。這是一條地基指向上層的隱性依賴:jedi-common 自己不 import jedi-iam, 但它產生的 FK 需要 jedi-iam 的表在 metadata 裡才 resolve 得動(NoReferencedTableError)。
  • 與 jedi-system-menu:兩張表都叫「menu」但不是一件事。 jedi-iam 有 ui_routes / route_capabilities / user_menu_entity / role_menu_entity / tenant_menu_entity / org_unit_menu_entity(domain/entities/),這些是前端路由樹與權限選單; jedi-system-menu 的 system_menus 是下拉選項字典(DEV live:AUDIT_METHOD 6 筆、DEVICE_TYPE 11 筆、 USER_STATUS 4 筆等 16 個 group、74 列)。兩者無 FK、無 relationship、無共用查詢。 看起來像但不是一件事。
  • 與 jedi-system-config:登入路徑上有一條實際相依,但方向是宿主層而非套件層。 <be>/app/auth/service/login_service.py:19,28,32 的宿主 LoginService 建構子同時收 jedi-iam 的 JediLoginService 與 jedi-system-config 的 SystemConfigDomainService。 另外 <be>/core/app_factory.py:144-146 在 register_identity() 之前就先 app.config.update(get_runtime_config()),而 get_runtime_config() 讀的是 system_configs(ROOT / RUNTIME_CONFIG)——JWT 效期、登入鎖定次數、密碼政策這些 iam 參數, 值存在 system-config 的表裡。且政策的定義(有哪些鍵/型別/預設/範圍)已在 jedi_iam/security_policy.py(<be>/infra/system_config/runtime_config.py:23-27 import 它)。 一邊定義 schema、另一邊存值,兩邊必須同時在場才有意義。

§4

jedi-system-config

Q1 它是什麼

一張 system_configs 表的 CRUD 與分組查詢——用 (tenant_id, group, key) → JSONB value 的形式 存系統設定。

README 與程式碼一致,而且異常誠實。<mono>/jedi-system-config/README.md:13-22 有一段 「🔴 驗收邊界誠實聲明」明列 D6 五件套只做到三項、自帶 api 層「❌ 未做」、拔掉測試「❌ 做不到—— 沒有 route 就沒有『拔掉註冊行 → API 消失』可驗。沒有拿假端點充數」。實查全部屬實 (無 api/ 目錄)。README :5-7 也自曝舊版是整段複製 jedi-issue 的,已於 FR-069 P3.5 重寫。

唯一與程式碼不符的一點:README :100-105 的 port 表寫「ISettingsReader 零使用—— 本套件自己就是設定的家,不需要再向外讀設定」「INotifier 零使用」。這個描述是對的, 但 README 沒提到 service 層有 SMTP / THIRD_PARTY_LOGIN 的硬編產品邏輯(見 Q6)—— 一個宣稱「只是設定表 CRUD」的套件,實際上認得「SMTP 這一組的密碼欄位叫 secret、 changePwd=false 時要沿用舊值」。

Q2 資料

自有表 1 張:

表 存什麼 關鍵欄位 證據
system_configs 系統設定(租戶級) uid / group / key / value(JSONB) / tenant_id / org_unit_id <mono>/jedi-system-config/jedi_system_config/infra/models/system_config.py:14

DEV live 實況(SET app.is_super_admin='t' 後查):20 列,7 個 group—— SMTP / NOTIFY_CONFIG / RUNTIME_CONFIG / STORAGE_CONFIG / THIRD_PARTY_LOGIN / WEB_IDEL_CONFIG / ISSUE_INTEGRATE_CONFIG。唯一約束 uq_system_configs_tenant_group_key (tenant_id, group, key), 掛了完整 4 條 RLS policy(select/insert/update/delete,條件 is_super_admin='t' OR app_tenant_allowed_for_session(tenant_id))。

對別人的表的參照:

  • (a) 真 FK:ORM 層有 2 條、DB 層 0 條——這是一個落差,值得記下。 ORM:system_config.py:12 class SystemConfig(Base, TenantScopedMixinModel) 繼承 mixin, 於是在 ENABLE_MULTI_TENANT=true 時(<be>/.env:53 確實是 true)產生 tenant_id → tenants.id(NOT NULL)與 org_unit_id → org_units.id (<mono>/jedi-common/.../tenant_mixin_model.py:14,22)。 DB:SELECT conname FROM pg_constraint WHERE conrelid='system_configs'::regclass 只回 pk_system_configs 與 uq_system_configs_tenant_group_key——沒有任何 FK; 且 \d 顯示 tenant_id | integer | 能否為 NULL:(空)=nullable,與 ORM 宣告的 NOT NULL 相反。 對照組:同樣掛 mixin 的 bulletins 在 DB 有 bulletins_tenant_fk / bulletins_org_fk 兩條真 FK。 也就是說 system_configs 這張表的 ORM 宣告與 live schema 對不上(ORM 較嚴、DB 較鬆)。
  • (b) ORM relationship 跨包:0 條。
  • (c) 軟參照:tenant_id 實際上就是軟參照 tenants.id(DB 無 FK),且 RLS policy app_tenant_allowed_for_session(tenant_id) 依賴它與 jedi-iam 的租戶樹對得起來。
  • (d) raw SQL 直打別人的表:套件內 0 條。但宿主端有 4 支直打這張表的 raw SQL—— <be>/infra/system_config/system_config_root_reader.py:64,99,125,189,200,246,303 (SELECT value FROM public.system_configs ...、UPDATE public.system_configs SET ...、 INSERT INTO public.system_configs ...),全部繞 RLS(:60 SET LOCAL app.is_super_admin='t')。 這是「套件的表被宿主繞過套件直接讀寫」的實例——理由寫在 :6-9:ROOT tenant 的列 對子租戶而言是祖先、RLS 看不到,fallback 必須繞。

表名產品詞彙:表名無。但資料層有:DEV 的 7 個 group 裡 ISSUE_INTEGRATE_CONFIG (GitLab/GitHub 整合)、STORAGE_CONFIG、WEB_IDEL_CONFIG 都是產品概念——不過那是資料不是 schema。

Q3 Port

  • 需要宿主提供的 port:宣告 2 張、實際使用 0 張—— ISettingsReader(get(key, default=None) -> Any,plugin.py:80)與 INotifier(send(...),plugin.py:90)。plugin.py:118-119 自己註明「目前零使用」, grep 全套件(排除 plugin.py 本身)確認 0 處呼叫。 README :100-105 給的理由:「本套件自己就是設定的家,不需要再向外讀設定」「設定變更要不要告警是產品規則」。 這是七支裡唯一一支「port 全部是樣板殘留」的(README 誠實承認:「插槽開齊是為了日後真需要時 簽名不用改,不是現在有東西被 port 化了」,並用兩條測試焊死此結論——tests/unittest/test_plugin_contract.py)。
  • 提供給別人的能力入口:SystemConfigService(app 層)、SystemConfigDomainService(domain 層)、 SystemConfigRepoImpl(infra 層)、SystemConfigDTO、common.enum.error_code.ErrorCode、 common.utils.common_util.generate_uuid。

Q4 消費者

  • (a) 主專案:27 處,橫跨 8 個模組—— <be>/app/auth/service/login_service.py:19(登入路徑)、 <be>/app/system_config/service/guarded_system_config_service.py:21,22、 <be>/app/system_config/service/storage_config_restore_app_service.py:36、 <be>/app/notification/service/notification_service.py:6、test_mail_service.py:29、 <be>/app/feedback/service/{feedback,issue}_service.py:8,10、 <be>/app/upload_file/service/managed_file_upload_service.py:16、 <be>/app/user_auth_provider/service/{ldap_service,user_auth_provider_service}.py:19,14、 <be>/infra/system_config/tenant_config_seed_writer.py:35、 <be>/api/system_config/routes/system_config_route.py:6,7(route 仍在宿主)、 以及 5 個 DI container(di_containers/{auth,system_config,notification,user_change_password,login}/)。
  • (b) 其他 jedi- 套件:0 處。* 除了套件自己與 harness,monorepo 內無人 import 它。
  • 它自己依賴:只有 jedi-common(pyproject.toml:23,9 處 import)。

Q5 執行期形狀

  • route:0 條(mount_api=True 掛出來的 blueprint 是空的——README :82-84 明說 「兩種模式的對外行為完全相同,差別只在 app.extensions 有沒有 jedi_system_config 這個 key」)。 對外的 5 條 system_config route 仍在宿主 <be>/api/system_config/routes/, 且 <be>/config/app_modules.py:19 的 REGISTERED_APPS 仍有 "system_config"。 README :26-38 給了不能上移的理由:3 條 route 的 handler 同時吃套件的 service 與主專案的 SharedConfigAppService(多租戶合併規則),另 2 條純吃主專案的 StorageConfigRestoreAppService(81 行) 與 SecurityPolicyAppService(207 行);主專案 app/system_config/ 合計 585 行全是產品知識 (實查:guarded_system_config_service.py 85 + security_policy_app_service.py 207 + shared_config_app_service.py 88 + storage_config_restore_app_service.py 81 + tenant_storage_config_seeder.py 118 = 579 行,與 README 的 585 大致吻合)。
  • 背景排程 / thread:無。
  • 對外 I/O:無(os.getenv 0 處,plugin.py:48 自述且實查屬實)。
  • 快取 / Redis:無。
  • 能不能單獨起成 process:harness 能起,但沒有端點可打。 harness/dev_app.py + docker-compose.yml(port 5492),README :120-124 說它驗的是 「建表+CRUD 斷言+register 掛載」。

Q6 通用性

(a) 能直接用,但會連帶吃到 SMTP 的產品邏輯。

(b) 寫死 Guidant / 產品知識的地方——這是本支最大的問題,且 README 沒提:

位置 寫死了什麼
<mono>/jedi-system-config/jedi_system_config/app/service/system_config_service.py:87-88 if existing_config.group == "SMTP": if existing_config.key != "*": ——認得 group 名 SMTP 與萬用 key "*"
同上 :93-97 if not data.get("changePwd", False): data["value"]["secret"] = existing_config.value["secret"] ——認得 value JSONB 裡有一個叫 secret 的欄位、以及一個叫 changePwd 的旗標
同上 :98-106 elif existing_config.group == "THIRD_PARTY_LOGIN": ——第二個硬編 group 名,同樣的 secret 沿用邏輯
同上 :126-127 if config.group == "SMTP" and config.key == '*': raise NotFound(...) ——刪除保護,硬編
同上 :131-139 def update_smtp_config(self, group, key, value) ——一個以 SMTP 命名的公開 method
<mono>/.../domain/service/system_config_domain_service.py:111 if existing_config.group == "SMTP" and existing_config.key == '*': ——domain 層也有一份
同上 :12-16,91-92,121-125,141-144 changePwd 處理散在 4 處,:12 docstring 寫「changePwd=False 時把既有列的 secret 沿用進本次要寫入的 value」
<mono>/.../infra/models/system_config.py:12 繼承 TenantScopedMixinModel → 強制 consumer 提供 tenants / org_units 兩張表在 metadata 裡(README :127-133 的「兩道宿主前提」)

一個工單系統裝上這支,會得到一個「認得 SMTP 密碼欄位叫 secret」的設定表 CRUD。 這些是 grep "SMTP"|changePwd 的全部命中(14 處),不是抽樣。

Q7 插件完整度

項目 有/無 證據
① api 層隨包 無(README 誠實標 ❌) 無 api/ 目錄
② register() 有 register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)(plugin.py:196-201);另有 create_blueprint()(:171)。四插槽齊
③ migrations 隨包 無 0 支
④ DI container 預設 無 宿主自己 wire(5 個 container)
⑤ 獨立 harness 有 harness/dev_app.py + docker-compose.yml(5492)
⑥ 接入 README 有 173 行,含誠實邊界聲明

做到 3/5(自己承認)。

Q8 糾纏對象

  • 與 jedi-notification:一邊存 SMTP 設定、一邊用它寄信,且套件 A 認得套件 B 的資料形狀。 <be>/app/notification/service/notification_service.py:6,7 同時 import 兩支; :58-60 get_notifier() 先 read_root_config_value("SMTP", "*") 拿設定, 再組 jedi-notification 的 SmtpEmailConfigDTO。而 system-config 套件內部本身就認得 group == "SMTP" 且知道它的 value 有 secret 欄位(見 Q6)—— 也就是 jedi-system-config 的程式碼已經知道 jedi-notification 的設定長什麼樣, 只是沒有 import 它。同一份資料(SMTP 帳密)在兩支套件的認知裡各有一半。
  • 與 jedi-iam:登入路徑上一交易同時碰兩邊,且 iam 的政策 schema 存在 config 的表裡。 ① <be>/app/auth/service/login_service.py:28 建構子同時收 iam 的 JediLoginService 與 config 的 SystemConfigDomainService。 ② <be>/infra/system_config/runtime_config.py:23-27 from jedi_iam.security_policy import RUNTIME_CONFIG_GROUP, build_defaults, coerce——「有哪些營運參數、型別、預設值、合法範圍」 的定義在 jedi-iam,值存在 jedi-system-config 的表裡(system_configs group=RUNTIME_CONFIG, DEV live 確有此 group)。<be>/core/app_factory.py:139-146 在 register_identity() 之前就把它讀進 app.config,註解 :141-142 明說「必須在發 token 之前——JWT 效期由 flask_jwt_extended 讀 app.config」。 ③ system_configs 的 RLS policy 呼叫 app_tenant_allowed_for_session(tenant_id), 而該 session 變數由 jedi-common 依 jedi-iam 的租戶 path 設定。
  • 與 jedi-common:資料層被 mixin 綁死。 system_config.py:12 繼承 TenantScopedMixinModel, README :127-133 因此列出兩道硬性宿主前提(ENABLE_MULTI_TENANT 要在 import 前設、 tenants/org_units 要在 metadata 裡)。
  • 看起來像但不是一件事:jedi-system-config 與 jedi-system-menu。兩者名字都是 system-*、 表結構長得幾乎一樣(都是 group + key + value 三欄字典),但 ① 租戶語意相反:system_configs 有 tenant_id + 4 條 RLS policy(租戶各有一份); system_menus 無 tenant_id、無 RLS、唯一約束是 (group, key) 全域唯一(全站共用一份)。 ② 消費方向相反:config 是後端讀來決定行為;menu 是前端讀來畫下拉選項。 ③ 兩表之間無 FK、無 join、無共用查詢(grep 確認)。

§5

jedi-system-menu

Q1 它是什麼

一張 system_menus 表——全站共用的下拉選項字典(group → 多個 key/value/sort), 給前端畫 select 用。裝上註冊一行就長出 5 條選單 API。

README 與程式碼一致。<mono>/jedi-system-menu/README.md:3 說「完整體插件——裝上註冊一行 就長出選單 API,拔掉一行就消失」→ 實查 jedi_system_menu/api/ 確有 api 層, <be>/core/app_factory.py:390 register_system_menu(app, ..., mount_api=True) 就是那一行, <be>/config/app_modules.py:21-26 對應的 "system_menu" 已註解掉、api/system_menu/ 目錄已刪。 README :120-127 的端點表 6 行與 api/__init__.py:154-160 的 5 條 add_resource 對得上 (6 行是因為 /system-menu 與 /system-menu/<int:id> 分列)。README :5-7 同樣自曝舊版抄自 jedi-issue。

Q2 資料

自有表 1 張:

表 存什麼 關鍵欄位 證據
system_menus 下拉選項字典 group / key / value / sort / enable / public <mono>/jedi-system-menu/jedi_system_menu/infra/models/system_menu.py:8

DEV live 實況:74 列、16 個 group——AGENT_TYPE(1) / ANS_TYPE(7) / AUDIT_METHOD(6) / BULLETIN_CATEGORY(4) / COMPLIANCE_FRAMEWORK_PUBLISH_STATUS(3) / DEVICE_TYPE(11) / ENABLE_STATUS(2) / FEEDBACK_TYPE(4) / PDF_PARSER(4) / PERMISSION_OPTION(3) / PROJECT_SELECT_RULE(4) / ssp_party_role(9) / STORAGE_TYPE(4) / SYSTEM_TYPE(3) / TASK_TYPE(5) / USER_STATUS(4)。 索引:pk_system_menus + uq_system_menus_group_key (group, key)。 無 tenant_id、無 RLS policy、無任何 FK(\d public.system_menus 2026-09-09)。

對別人的表的參照:(a)(b)(c)(d) 全部 0 條。 model 是 class SystemMenu(Base)(:6)——刻意不繼承 TenantScopedMixinModel, 所以它是七支裡唯一一支「資料完全零耦合、連 mixin 都不吃」的表。 plugin.py:38-40 給了理由:system_menus 是全站共用的系統字典,公開等於任何人都能改到全站 UI 的下拉選項。

表名產品詞彙:表名與套件源碼皆無——grep AUDIT|ssp|COMPLIANCE|DEPARTMENT|"[A-Z_]{4,}" 在 jedi_system_menu/ 零命中。 但資料層命中很重:live 的 16 個 group 裡 AUDIT_METHOD(稽核方法)、ssp_party_role(SSP 當事人角色)、 COMPLIANCE_FRAMEWORK_PUBLISH_STATUS(合規框架發布狀態)、PROJECT_SELECT_RULE 全是 Guidant 產品詞。 這是 schema 通用、資料產品化的典型——換個產品只要換資料,程式碼一行不用改。 主專案有兩處硬編對照這些 group:<be>/app/oscal/service/export/ssp_docx_generator.py:31 (註解「對齊 C1 system_menus group='ssp_party_role' 9 個 OSCAL 標準角色」)、 <be>/test/test_ap_report_parser_airasia.py:5(「AUDIT_METHOD codes 來源:DEV DB public.system_menus」)。

Q3 Port

需要宿主提供的(<mono>/jedi-system-menu/jedi_system_menu/plugin.py:117-138):

port 簽名 預設 用在哪
services.system_menu_service Callable[[], Any](provider,不是實例) ❌ route 取 service
auth_required Callable[[Callable], Callable] ❌ 刻意無預設 全部 5 條 route
capability_required Callable[[str], Callable] ❌ 刻意無預設 寫入 3 條
platform_admin_required Callable[[Callable], Callable] ❌ 刻意無預設 管理 5 條(消費端點例外)
response_builder Callable[..., Any] ✅ 退回 jedi-common return_response 全部

🔴 三個守門無預設的理由(plugin.py:38-40 + README :112-117):預設放行會讓「忘記傳」 變成一整組無聲的公開端點,而 system_menus 是全站共用字典(無 tenant_id、無 RLS、 (group,key) 全域唯一),公開等於任何人都能改到全站 UI 的下拉選項,同時服務照常起、 健康檢查照樣綠。缺欄位時 _assert_api_wiring()(plugin.py:206)當場拒絕掛載。 config 另有三個能力點名可覆寫:system-menu.create/update/delete(plugin.py:77-79,111-113)。

  • 提供給別人:SystemMenuService / SystemMenuDomainService / SystemMenuRepoImpl / plugin.register / SystemMenuAdapters / SystemMenuServices / api.FROZEN_URLS。
  • 宣告了但零使用的 port:無(五張全部在用)。schema_extensions 插槽開齊但無產品使用(plugin.py:143-147)。

Q4 消費者

  • (a) 主專案:5 處,全是接線 —— <be>/infra/system_menu/system_menu_plugin_wiring.py:35,50(組 adapters)、 <be>/core/app_factory.py:388(register)、 <be>/di_containers/system_menu/system_menu_containers.py:2,3,4(DI wire 三層)。 沒有任何業務程式碼直接呼叫它——這是七支裡宿主耦合最薄的一支。
  • (b) 其他 jedi- 套件:0 處。*
  • 它自己依賴:只有 jedi-common(pyproject.toml:35,floor >=0.0.33,15 處 import)。

Q5 執行期形狀

  • route:5 條,blueprint system-menu、prefix /api/1.0(plugin.py:66,74): GET /system/menu/<group>(僅登入,刻意不加 platform 守門)、 POST /system/menus、GET /system/category-menu、POST /system-menu、 PUT|DELETE /system-menu/<int:id>(後四條 = 登入+platform-admin,寫入再加能力點)。 凍結清單 FROZEN_URLS(api/__init__.py:166-172),測試做集合比對。
  • 背景排程 / thread:無。
  • 對外 I/O:無(os.getenv 0 處,plugin.py:49 自述且實查屬實)。
  • 快取 / Redis:無。
  • 能不能單獨起成 process:不能——它是七支裡唯一「有 route 卻沒有 harness」的 (ls jedi-system-menu/ 無 harness/)。README :15-19 的表格聲稱「D6 五件套全做到(含拔掉測試)」, 但那張表沒把 harness 列進五件套;實查 harness 確實不存在。

Q6 通用性

(a) 能直接用,而且是七支裡最乾淨的一支。 一張 group/key/value/sort/enable/public 的字典表 + CRUD API,工單系統要「工單類型」「優先級」下拉直接可用。

(b) 寫死 Guidant 產品知識:套件源碼零命中(grep AUDIT|ssp|COMPLIANCE|DEPARTMENT = 0)。 唯一「像產品知識」的只有三個能力點預設名 system-menu.create/update/delete (plugin.py:77-79),而它們是可覆寫的 config 欄位(:111-113),不是硬編。 DEFAULT_URL_PREFIX = "/api/1.0" / DEFAULT_BLUEPRINT_NAME = "system-menu" 同樣可覆寫。

唯一的通用性缺口不在程式碼、在守門設計:CM-779 的裁決(選單管理收歸 root tenant, 但前台消費端點是例外不加守門,api/__init__.py:118-127)是 Guidant 的多租戶政策。 新產品若是單租戶,platform_admin_required 就無意義卻仍是必填欄位——只能傳一個恆真的 decorator。

Q7 插件完整度

項目 有/無 證據
① api 層隨包 有 jedi_system_menu/api/,5 條 route + FROZEN_URLS
② register() 有 register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)(plugin.py:256-261);create_blueprint()(:227);_assert_api_wiring()(:206)
③ migrations 隨包 無 0 支
④ DI container 預設 無 宿主 wire(<be>/di_containers/system_menu/)
⑤ 獨立 harness 無 無 harness/ 目錄
⑥ 接入 README 有 169 行,含端點表、port 表、常見坑

做到 4/6(README 自稱五件套全做到,但 harness 這項實查不存在)。

Q8 糾纏對象

  • 與 jedi-iam:兩張都叫「menu」但不是一件事(同 jedi-iam Q8 那條的反向敘述)。 jedi-iam 的 ui_routes / route_capabilities / *_menu_entity 是前端路由樹與權限選單; 本支的 system_menus 是下拉選項字典。無 FK、無 relationship、無共用查詢。 唯一真實關聯是執行期的守門依賴:本支的 3 個守門 port 由宿主接成 jedi_iam.authz.require_capability / require_platform_admin_route (<be>/infra/system_menu/system_menu_plugin_wiring.py),且能力點 system-menu.create/read/update/delete 4 筆確實存在於 jedi-iam 的 capabilities 表 (DEV live 實查)。一邊定義能力點名、另一邊存能力點資料。
  • 與 jedi-system-config:看起來像但不是一件事(理由已在 system-config 的 Q8 詳列: 租戶語意相反、消費方向相反、兩表零關聯)。
  • 與 jedi-common:只有 library 依賴,無資料耦合。 是七支裡唯一不繼承 TenantScopedMixinModel 的有表套件,所以不吃「tenants/org_units 必須在 metadata 裡」那道宿主前提。README :150-155 記載的坑只有版本 floor(>=0.0.33, 因為 route 用 jedi_common.enums.schema_code.AUTH_PARAMS 與 utils.response_util, 兩者是 FR-069 P7 才上移的)與 marshmallow <4。
  • 與產品的糾纏在資料不在程式碼:live 的 ssp_party_role 9 筆被 <be>/app/oscal/service/export/ssp_docx_generator.py:31 對齊、AUDIT_METHOD 6 筆被 AP 報告 parser 使用。拔掉這張表的資料會壞掉 SSP 匯出與 AP 解析,但拔掉這支套件的程式碼 只會讓 5 條 API 消失(<be>/core/app_factory.py:381-384 註解明說:「選單資料本身不受影響 (表還在、別的模組若直接用 SystemMenuService 也照常),消失的只有 HTTP 端點」)。

§6

jedi-notification

Q1 它是什麼

「怎麼把一則訊息送出去」的三條管道實作——SMTP 寄信 / Discord webhook / Telegram bot。 不管「什麼時候寄、寄給誰、信裡寫什麼」,那些留在宿主。

README 與程式碼一致。<mono>/jedi-notification/README.md:3-9 說「裝上、註冊一行,產品就長出 『測試信』端點;拔掉那一行,端點消失、產品其餘功能照常跑(因為 12 個消費點走的是 library 用法)」 → 實查 api/__init__.py:104,110 確實只有 1 條 route(/mail/test), <be>/core/app_factory.py:364-369 的註解逐字印證同一件事:「註解掉 → POST /api/1.0/mail/test 404, 但通知功能本身完全不受影響——12 個消費點(flow_control / task_survey / flow_engine / notify_config / license / jedi-iam 的 OTP 信)走的是套件的 library 用法,不經 route」。 README :40-45 對 jedi-iam 供應鏈的描述也已交叉驗證屬實(見 jedi-iam Q8)。

Q2 資料

自有表:0 張。 grep __tablename__ 零命中,全套件無 infra/models/、無 ORM model、 無 Base import。它是七支裡唯一完全無狀態的。

對別人的表的參照:(a)(b)(c)(d) 全部 0 條。 它連 SQLAlchemy 都不在 runtime 相依裡 (pyproject.toml:10-32 只有 Flask 三件套 + marshmallow + pydantic + requests + jedi-common)。

表名產品詞彙:N/A(無表)。

Q3 Port

  • 需要宿主提供的(<mono>/jedi-notification/jedi_notification/plugin.py:99-117):
port 簽名 預設
services.test_mail_service Callable[[], Any](provider) ❌
auth_required Callable[[Callable], Callable] ❌ 刻意無預設
capability_required Callable[[str], Callable] ❌ 刻意無預設
response_builder Callable[..., Any] ✅

config 帶一個能力點名 test_mail_capability(plugin.py:96)。缺守門 → _assert_api_wiring()(:188)拒絕掛載。 🔴 test_mail_service 之所以是 port 而非套件內建:README :37 說明 「依收件人網域挑 SMTP 設定、查無退回 *」是宿主的事,因為那是 Guidant AI 設定頁的資料模型 (group=SMTP、key=網域)長出來的政策。實作在 <be>/app/notification/service/test_mail_service.py(121 行)。

  • 它自己的插件點(provider 註冊表,不是宿主 port):register_adapter(provider, adapter, config_dto) + get_notification_adapter(provider, config)(ports/notification_provider/notification_factory.py:42,51)。 ports/ 內的 TYPE 表刻意是空的(:39),由 infra/__init__.py 在 import 時 自己送上門登記三個 adapter。:1-28 有完整的依賴反轉說明——原本 ports/ 直接 import infra/ 三個 adapter, 四條架構測試都抓到方向反了;搬檔案解不掉(test_no_circular_dependencies 按模組名比對), 真正的問題是「介面層認得所有實作」本身。要加第四條管道只要在 infra/__init__.py 加一行,ports/ 不動。
  • 提供給別人:NotificationService(app/service/notification_service.py:7)、 四支 DTO(MailRequestDTO / TelegramRequestDTO / DiscordRequestDTO / SmtpEmailConfigDTO / DiscordConfigDTO / TelegramConfigDTO)、plugin.register / NotificationAdapters / NotificationServices。
  • 宣告了但零使用的 port:無。

Q4 消費者

  • (a) 主專案:14 處,3 個模組 + 1 個接線點—— <be>/app/notification/service/notification_service.py:7,8,11,12,13(主要 wrapper,110 行)、 <be>/app/notification/service/test_mail_service.py:26,27,28、 <be>/app/notify_config/service/notify_config_test_service.py:18,19,22,23(測 Discord/Telegram 連線)、 <be>/infra/notification/notification_plugin_wiring.py:30 + <be>/core/app_factory.py:373(接線)。 README :33-40 列的 12 個消費點(flow_control / task_survey / flow_engine / notify_config / license / jedi-iam)是透過 <be>/app/notification/service/notification_service.py 這層 wrapper 間接消費的, 不是直接 import 套件。
  • (b) 其他 jedi- 套件:0 處。 特別是 jedi-iam 零 import*(該邊已於 FR-069.15 刻意斷開, <mono>/jedi-iam/pyproject.toml:51-53)。
  • 它自己依賴:只有 jedi-common(pyproject.toml:31,僅 3 處 import——是全 monorepo 對 jedi-common 依賴最輕的套件)。

Q5 執行期形狀

  • route:1 條 ——POST /api/1.0/mail/test(api/__init__.py:104,FROZEN_URLS 於 :110)。
  • 背景排程 / thread:套件自己沒有。非同步是宿主做的 (<be>/common/iam_ports.py:59-62 threading.Thread(target=send_mail_notification).start())。
  • 對外 I/O:三種,全部是網路 —— ① SMTP:infra/smtp_mail/smtp_mail_adapter.py:1,39 smtplib.SMTP(host, port, timeout=60); ② Discord webhook:infra/discord/discord_adapter.py:1 import requests; ③ Telegram bot API:infra/telegram/telegram_adapter.py:1 import requests。 無 DB、無檔案系統、無 subprocess。
  • 快取 / Redis:無。
  • 能不能單獨起成 process:能,而且是七支裡最容易的。 harness/dev_app.py 不需要資料庫、不需要任何主產品程式碼(README :47-49 明說), 178 個單元測試不需 DB 不需網路(:58)。NotificationService 本體只有 17 行 (app/service/notification_service.py,建構子收 provider: str + config: dict, send_notification() 三行)——無狀態、可水平擴充、天生適合服務化。

Q6 通用性

(a) 能直接用,是七支裡通用性最高的。 「怎麼跟 SMTP 講話」「Discord webhook 的 JSON 長怎樣」 每個產品完全一樣(README :7-9 的立論)。

(b) 寫死 Guidant 產品知識:程式碼零命中。grep CMMC|Guidant|POAM|ssp|稽核 在 jedi_notification/ 只命中 註解與 docstring: plugin.py:6(「產品各處(稽核流程通知、問卷指派…)」)、 plugin.py:37(「『依收件人網域挑 SMTP 設定』| 宿主 | 那是 Guidant AI 設定頁的資料模型」)、 harness/dev_app.py:35,61(說明 harness 不提供真的 TestMailService,因為那裝的是 Guidant AI 的產品知識)。 這三處全是在解釋「什麼不在疆界內」,不是洩漏。 os.getenv 0 處(plugin.py:46 自述,實查屬實)。

唯一可議:DEFAULT_TEST_MAIL_CAPABILITY(plugin.py:96)與 DEFAULT_URL_PREFIX="/api/1.0", 兩者都是可覆寫 config。

Q7 插件完整度

項目 有/無 證據
① api 層隨包 有 jedi_notification/api/,1 條 route
② register() 有 register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)(plugin.py:237-242);create_blueprint()(:208);_assert_api_wiring()(:188)
③ migrations 隨包 無(也不需要——無表) 0 支
④ DI container 預設 無 宿主 wire
⑤ 獨立 harness 有 harness/dev_app.py(無需 DB,故也無 docker-compose)
⑥ 接入 README 有 276 行,含 quickstart / port 表 / 端點表 / 常見坑

做到 5/5(migrations 對無表套件不適用)。與 jedi-log-forwarding 並列最完整。

Q8 糾纏對象

  • 與 jedi-system-config:一邊存 SMTP 帳密、一邊拿去連 SMTP——最像「同一件事的兩半」的一對。 證據鏈:<be>/app/notification/service/notification_service.py:22 建構子收 SystemConfigService;:58 get_notifier() 呼叫 read_root_config_value("SMTP", "*") 拿設定後組 SmtpEmailConfigDTO;:41-47 get_channel_config(channel) 讀 NOTIFY_CONFIG/<channel> 決定 Discord/Telegram 的 webhook。 更強的證據是反向的:jedi-system-config 的套件內部已經認得 group == "SMTP"、key == "*"、value["secret"]、changePwd 旗標 (<mono>/jedi-system-config/.../system_config_service.py:87-106,126,14 處硬編)—— 也就是設定套件已經知道通知套件的資料形狀,只是沒有 import 它。 DEV live 佐證:system_configs 的 7 個 group 裡 SMTP 與 NOTIFY_CONFIG 兩個 (佔 2/7)純粹是為通知而存在。
  • 與 jedi-iam:靜態解耦、動態耦合的供應鏈(詳見 jedi-iam Q8)。 <mono>/jedi-notification/README.md:40-45 自己記載這條鏈;宿主的中介在 <be>/common/iam_ports.py:46-62 + <be>/core/app_factory.py:301-311。 兩支套件互不 import,但拔掉 jedi-notification,jedi-iam 的 OTP 信就寄不出去 (會優雅降級成「產碼但不寄信」,<be>/common/iam_ports.py:91-92)。
  • 看起來像但其實不是一件事:與 jedi-log-forwarding。 兩者都是「把東西送到外部系統」、 都用 socket/HTTP、都要求宿主注入守門與 response_builder。但 ① 資料流向與觸發者不同:notification 是業務事件觸發的單筆推送(有人做了某事 → 寄一封信); log-forwarding 是logging 管線的旁路鏡像(每一條 log 自動複製一份), 掛在 QueueHandler 上、由背景 listener thread 送。 ② 失敗語意相反:notification 送失敗會回 False 讓呼叫端知道(notification_service.py:15-17); log-forwarding 一律靜默吞掉(common/gelf_handler.py:71,163-166 明說「所有 socket 例外一律吞掉並降級」)。 ③ 無任何共用程式碼、無共用設定、無共用表(一個無表、一個有自己的表)。
  • 與 jedi-common:最輕的依賴(僅 3 處 import——例外類、回應信封、AUTH_PARAMS)。

§7

jedi-log(import 名 jedi_api_log)

Q1 它是什麼

API 存取紀錄——每一筆 HTTP 請求的 URL / 參數 / 使用者 / 來源 IP / 回應 / 耗時, 寫進 api_logs 表,並提供「操作日誌」頁的分頁查詢與 Excel 匯出。

README 與程式碼一致,且開宗明義處理了最大的誤解。<mono>/jedi-log/README.md:11-16 的表格 說這支 dist 曾 ship 兩個 import 名,jedi_system_log 已於 FR-069 P3.6(CM-1472)整包刪除 → 實查 ls jedi-log/ 確實只剩 jedi_api_log/,pyproject.toml:61-63 的 packages 只 include 一個。

🔴 決策者指定要查的「jedi-log 剩下哪一半」——實查結論:剩 jedi_api_log,1,305 行,活的。 刪掉的那半(jedi_system_log)為什麼非刪不可,pyproject.toml:54-60 + README :22-30 給了三層理由, 且全部可交叉驗證: ① 它與 jedi-common 宣告同一張 system_logs 表,在 jedi-common 已載入時(也就是必然) import 即拋 InvalidRequestError: Table 'system_logs' is already defined ——「不是『暫時沒人用』,是『用不了』」。 ② 該表的資料全部由 jedi-common 的 DBLogHandler 寫入,jedi_system_log 對它寫過 0 列。 證據可驗:path / func_name / line_no 三欄只存在於 jedi-common 那份 model (<mono>/jedi-common/.../models/system_log.py:14-17),而 DEV live \d public.system_logs 確有這三欄且 663,249 列——README 說「實查全部帶值」。 ③ 其 model 已漂移成 9 欄、不符 live schema 的 12 欄。 系統日誌的 canonical 落地點是 jedi_common.logger.db_log/。

Q2 資料

自有表 1 張:

表 存什麼 關鍵欄位 證據
api_logs API 存取紀錄 act_time / user_uid+user_name / url+method / source_ip+server_ip / params+request+response / duration <mono>/jedi-log/jedi_api_log/infra/models/api_log.py:7

DEV live 實況:116,319 列,RANGE(act_time) 月分區(5 個分區)+ BRIN 索引, 無 FK、無 tenant_id、無 RLS(\d public.api_logs)。 model 是 class ApiLog(Base)(:6)——不繼承 TenantScopedMixinModel。

⚠️ model 與 live schema 有一處型別落差:api_log.py:27 宣告 duration = Column(Float, ...), 但 DB 實際是 duration | numeric | 預設 0(\d 實查)。Float(double precision) vs numeric 不同型別。

對別人的表的參照:(a)(b)(c)(d) 全部 0 條。 軟參照:user_uid / user_name 由宿主 middleware 填入 (<be>/common/middleware/app_mw.py:52-60 的 CreateApiLogDTO),值來自 get_user_context(), 不建 FK。

表名產品詞彙:表名無。但匯出檔名有: <mono>/jedi-log/jedi_api_log/app/service/api_log_service.py:58,76 硬編 f"user_log_{timestamp}.xlsx" 與 f"user_log_{timestamp}.zip"——user_log 是 Guidant 前端頁面的叫法。

Q3 Port

需要宿主提供的(<mono>/jedi-log/jedi_api_log/plugin.py:118-136):

port 簽名 預設
services.api_log_service Callable[[], Any](provider) ❌
auth_required Callable[[Callable], Callable] ❌ 刻意無預設
platform_admin_required Callable[[Callable], Callable] ❌ 刻意無預設
response_builder Callable[..., Any] ✅

🔴 本套件是 P3.5 七支裡外洩後果最嚴重的一支(plugin.py:122-126 + README :96-101): 預設放行會讓「忘記傳」變成無聲的公開端點,而這兩條端點吐的是全站 API 存取紀錄 ——每一筆請求的 URL、參數、使用者、來源 IP,等於把整份稽核軌跡攤開給任何人; 同時服務照常起、健康檢查照樣綠。缺欄位時 _assert_api_wiring()(:204)當場拒絕掛載。 🔴 用 platform-admin 而非能力點是刻意的(README :88-92):搬遷前就是 @jwt_required() + @require_platform_admin_route,沒有能力點——操作日誌是全域共用資源。 故 ApiLogPluginConfig(:104-114)無能力點欄位,只有 url_prefix / blueprint_name。

  • 提供給別人:ApiLogService、CreateApiLogDTO / UpdateApiLogDTO、 common.enum.code.LogTypeCode、plugin.register / ApiLogAdapters / ApiLogServices、api.FROZEN_URLS。
  • 宣告了但零使用的 port:無(四張全在用)。

Q4 消費者

  • (a) 主專案:10 處,2 個用途 —— ① 寫入路徑(middleware,最重要):<be>/common/middleware/app_mw.py:5,6,7,8 import CreateApiLogDTO / UpdateApiLogDTO / ApiLogService / LogTypeCode, 在 before_request(:41)建立紀錄、after_request 回填回應與耗時。 這是套件最主要的消費形式,而且不經 route——<be>/core/app_factory.py:410 註解明說 「日誌寫入本身不受影響——那是 middleware 幹的事,不經這兩條 route」。 ② 接線:<be>/infra/api_log/api_log_plugin_wiring.py:34 + <be>/core/app_factory.py:419。 對應的 <be>/config/app_modules.py:48-54 的 "log" 已註解掉、api/log/ 目錄已刪。
  • (b) 其他 jedi- 套件:0 處。*
  • 它自己依賴:只有 jedi-common(pyproject.toml:39,floor >=0.0.33,11 處 import)。

Q5 執行期形狀

  • route:2 條,blueprint api-log、prefix /api/1.0(api/__init__.py:101-102): POST /log/api-logs(分頁查詢)、GET /log/api-logs/export(匯出 zip)。 FROZEN_URLS 於 :108-111。README :129-131 記載「CM-1461 死路徑:本模組零命中」(已寫成測試焊死)。
  • 背景排程 / thread:無。
  • 對外 I/O:檔案系統(記憶體內) ——匯出路徑用 pandas + openpyxl 產 xlsx、再用 zipfile 壓成 zip,全程 BytesIO 不落地(app/service/api_log_service.py:63,71)。 另用 werkzeug.utils.secure_filename(README :158-160 說明這是 P3.5 才補上顯式宣告的, 原本靠 Flask 傳遞相依撐著)。無網路 I/O、無 subprocess。
  • 快取 / Redis:無。
  • runtime 相依偏重:pandas==2.2.2 + openpyxl>=3.1.4(pyproject.toml:37-38) ——README :24 說「實查 6 處 import」。一個「查 log」的套件拖 pandas 進來, 是它與其他六支最大的體積差異。
  • 能不能單獨起成 process:不能——無 harness(ls jedi-log/ 無 harness/)。 ⚠️ 但更關鍵的限制是「寫入端在宿主 middleware 裡」:真要服務化, api_logs 的寫發生在每一個 HTTP 請求的 before/after_request(<be>/common/middleware/app_mw.py), 而讀在這兩條 route。拆成獨立 process 會讓寫入變成跨進程呼叫, 每個請求多一次網路往返——這是本支服務化的實質障礙。

Q6 通用性

(a) 能直接用——「記錄每筆 API 請求並可查可匯出」是所有產品都要的。

(b) 寫死 Guidant 產品知識:

位置 寫死了什麼
<mono>/jedi-log/jedi_api_log/app/service/api_log_service.py:58,76 匯出檔名前綴 user_log_ ——Guidant 前端「操作日誌」頁的叫法,非通用詞
同上 :66 df_clean.to_excel(writer, index=False, header=False) ——匯出無表頭。這是配合特定前端/客戶格式的決定,新產品拿到會是一份沒有欄位名的 Excel
plugin.py:113-114 DEFAULT_URL_PREFIX="/api/1.0" / DEFAULT_BLUEPRINT_NAME="api-log"
<mono>/jedi-log/README.md:161-163(行為,非硬編) 「列表預設排序是後端強制的:FE 未帶排序時後端塞 created_at desc」——因為 jedi-common 的 BaseRepository 預設 created_at ASC 會把今日資料排到最後幾頁。README 標「這是刻意的補償,不要拿掉」。是為特定 FE 行為做的補償

os.getenv 0 處(plugin.py:58 自述,實查屬實)。跨 jedi 套件 import 0 處。

Q7 插件完整度

項目 有/無 證據
① api 層隨包 有 jedi_api_log/api/,2 條 route + FROZEN_URLS
② register() 有 register(app, adapters=None, config=None, schema_extensions=None, mount_api=True)(plugin.py:254-259);create_blueprint()(:225);_assert_api_wiring()(:204)
③ migrations 隨包 無 0 支。api_logs 建表與分區在 <be>/scripts/sql/2026-06-03-log-tables-partitioning.sql
④ DI container 預設 無 宿主 wire
⑤ 獨立 harness 無 無 harness/
⑥ 接入 README 有 163 行

做到 3/6(與 system-menu 同為「有 route 沒 harness」)。

Q8 糾纏對象

  • 與 jedi-common:曾經是同一張表的兩份 model,現在是同一條請求裡寫的兩張表。 歷史面已在 Q1 詳述(jedi_system_log 因與 jedi-common 撞 system_logs 表而整包刪除)。 現況面的四項證據: ① 同一次 HTTP 請求同時寫兩張表——api_logs 由 <be>/common/middleware/app_mw.py:41,52 的 before/after_request 寫;system_logs 由同一請求內任何 logger 呼叫經 DBLogHandler 寫。 ② 兩表在同一支 migration 裡被同樣處理—— <be>/scripts/sql/2026-06-03-log-tables-partitioning.sql:2 檔頭「api_logs / system_logs 改 RANGE(act_time) 月分區 + BRIN + retention 維護函式」,retention 設在同一張 VALUES 表 (:136 ('api_logs', 90), ('system_logs', 180))。 ③ 基線清理腳本把兩者並列——<be>/scripts/sql/2026-08-17-fr065-t20b-baseline-clear-operation-traces.sql:11,45,74,99 同一支 TRUNCATE 兩張表。 ④ 出貨基線產生器把兩者的月分區用同一條 regex 過濾—— <be>/scripts/init/gen_schema_sql.sh:64 MONTH_PARTITION_RE = re.compile(r"^(api_logs|system_logs)_\d{4}_\d{2}(_|$)")。 這四條都是「一邊沒有另一邊就沒意義」的證據——維運上它們已經是一件事。 但資料層是分開的:兩表無 FK、無共用欄位語意(api_logs 有 url/params/response, system_logs 有 path/func_name/line_no),兩份 model 各自獨立。
  • 與 jedi-log-forwarding:都叫「log」但不是一件事,而且分工是明確的。 log-forwarding 轉發的是 logging 鏈上的 record(也就是最終會進 system_logs 的那些), 不碰 api_logs——實查 jedi_log_forwarding/ 零處 import jedi_api_log、 零處提及 api_logs。兩者的接觸點只有一個:兩支都是「log 相關」而在 <be>/scripts/sql/ 的 維運腳本裡被一起想到。看起來像但不是一件事。
  • 與 jedi-iam:只有守門的執行期依賴。 兩條 route 的 platform_admin_required 由宿主接成 jedi_iam.authz.require_platform_admin_route。能力點 log.read 確實存在於 capabilities 表(DEV live 實查),但 README :88-92 說明本模組刻意不用能力點、 只用 platform-admin 這一軸——所以那筆 log.read 資料目前對本套件而言是無用的。

§8

jedi-log-forwarding

Q1 它是什麼

把產品自己的 log 同步送一份到客戶的日誌伺服器(rsyslog / Graylog / ELK)—— 負責「怎麼送、送去哪、送什麼、送不出去怎麼辦」,附帶一個設定頁的 CRUD API。

README 與程式碼一致,是七支裡最詳盡的一份(281 行)。<mono>/jedi-log-forwarding/README.md:3-4 說「裝上、註冊一行,產品就長出『系統 log 轉發』這整套能力;拔掉那一行,功能消失、產品照常跑」 → plugin.py:37 逐字印證:「宿主註解掉註冊行 → 服務正常啟動、/api/1.0/log-forwarding 回 404、 其餘功能不受影響」。README 的 quickstart(:29-77)不只驗 route 掛得起來,還用 --sink 起迷你接收器把「設定頁存檔 → log 真的送到對面」整條打通——這是七支裡唯一做到端到端 harness 的。

Q2 資料

自有表 1 張,且是唯一一支放在自訂 schema 的:

表 存什麼 關鍵欄位 證據
config.log_forwarding_settings 轉發設定(一套系統一列) enabled / protocol(syslog|gelf) / transport(udp|tcp) / host+port / forward_app_log+forward_audit_events / tenant_id(可空) <mono>/jedi-log-forwarding/jedi_log_forwarding/infra/model/log_forwarding_setting.py:29-30

schema 名由 SETTINGS_SCHEMA = os.getenv("LOG_FORWARDING_SETTINGS_SCHEMA", "config")(:14)決定, 必須在 import 本模組之前設(SQLAlchemy 在 class 定義當下就把 __table_args__ 定死)。 DEV live:1 列(config.log_forwarding_settings)。

🔴 刻意不繼承 TenantScopedMixinModel(:24-27 有明確理由):這是平台層設定表, tenant_id 是「預留的覆寫維度」而非租戶隔離欄位——它可以是 NULL,而 mixin 的欄位是 NOT NULL + FK + RLS,硬套會讓全域列根本寫不進去。 🔴 刻意不掛 RLS(migrations/001-*.sql:23-30):轉發 handler 的掛載發生在 「沒有 request 脈絡」的背景執行緒(QueueListener/watcher),RLS 需要的 app.allowed_tenant_paths 當下不存在,掛了只會讓背景讀取恆空。

對別人的表的參照:

  • (a) 真 FK:0 條。 tenant_id 是裸 BIGINT(migrations/001:35),刻意不建 FK。
  • (b) ORM relationship 跨包:0 條。
  • (c) 軟參照:2 條 ——tenant_id(log_forwarding_setting.py:34,「NULL=全域設定;預留租戶層覆寫」, 軟指向 tenants.id);created_user / updated_user(:43-44,varchar 255,存 login_name, 軟指向 users.login_name,由宿主的 IUserNameResolver 換成暱稱)。
  • (d) raw SQL:有 2 句,但只打自己的表。 common/settings_reader.py:87,94 SELECT {_COLUMNS} FROM {table} WHERE tenant_id = :tid / IS NULL LIMIT 1, table 預設是自己的 config.log_forwarding_settings。 🔴 這支用的是繞 RLS 的獨立唯讀 session(:10-11 檔頭明說),理由是 handler 鏈的組裝 發生在啟動期(DI 未 wire 完)與 watcher 背景執行緒(無 user_context、無 session_scope), 走一般 repo 路徑會撞 @transaction 需要有人先開 session_scope。 「API 端的 CRUD 一律走正規 DDD 路徑(infra/),這支只服務背景掛載」——兩條讀取路徑並存是刻意的。

表名產品詞彙:無。log_forwarding_settings 完全通用。

Q3 Port

需要宿主提供的(<mono>/jedi-log-forwarding/jedi_log_forwarding/plugin.py:83-126)—— 是七支裡唯一有「必填位置參數」的(前三個沒有預設值,dataclass 層級就強制):

port 簽名 預設 用途
read_required Callable[[Callable], Callable] ❌ 必填 GET 守門
update_required Callable[[Callable], Callable] ❌ 必填 PUT/POST 守門(與讀分開)
current_user Callable[[], str] ❌ 必填 寫審計欄位
user_name_resolver IUserNameResolver(見下) ✅ None 補 *_user_name 暱稱
audit AuditHook ✅ 套件自帶同格式版 稽核事件寫哪
audit_logger_name str ✅ "jedi_log_forwarding" 🔴 見 Q6
response_builder ResponseBuilder ✅ jedi-common return_response 回應信封

config 另有 ForwardingConfig(common/settings.py):hostname callable / app_name / watch_interval_seconds(預設 30)/ ops_logger_name。

🔴 決策者指定要查的「IUserNameResolver 與 jedi-common 的是否同形狀」——實查結論:簽名逐字相同, 但是兩個獨立的 ABC,宿主為此寫了兩份實作。

  • 本支的:<mono>/jedi-log-forwarding/jedi_log_forwarding/domain/repository/user_name_resolver.py:18-22 class IUserNameResolver(ABC) / def resolve(self, login_names: Iterable[str]) -> Dict[str, str]

  • jedi-common 的:<mono>/jedi-common/jedi_common/identity/resolvers.py:63-80 class IUserNameResolver(ABC) / def resolve(self, login_names: Iterable[str]) -> Dict[str, str]

  • 簽名、參數名、回傳型別、「查不到就不放進 dict」的契約,全部一致。 jedi-common 那份的 docstring :15-16 直接寫「參考實作:jedi-log-forwarding 的 IUserNameResolver(P3 樣板) ——本檔即它的一般化與擴編」。也就是說 jedi-common 那份是後來從這支抄過去再擴編的 (擴成三件套:user_name / tenant / org_unit + 聚合包 IdentityContext)。

  • 但這支沒有改用 jedi-common 的版本——log_forwarding_app_service.py:18 仍 from jedi_log_forwarding.domain.repository.user_name_resolver import IUserNameResolver。

  • 代價在宿主端:兩份幾乎一樣的 adapter,class 名還一模一樣。 ① <be>/infra/log_forwarding/user_name_resolver.py:19 class AuthUserNameResolver(IUserNameResolver) ——繼承本套件的 ABC(:14 import)。 ② <be>/core/upload_file_wiring.py:53 class AuthUserNameResolver(同名不同檔) ——走 jedi-common 的統一 port(:116 from jedi_common.identity import IdentityContext)。 該檔 :62-63 的 docstring 自己承認:「形狀與 infra/log_forwarding/user_name_resolver.py 相同(P3 樣板), 差別只在這裡走 jedi-common 的統一 port 定義(D8 收斂)而非套件自訂的介面」。 ③ 兩份實作邏輯幾乎一樣(都是拿 jedi_iam.UserQueryEntity(_in_login_name=names) 批次查、 回 {login_name: nickname}),唯一差別是 upload_file 那份多了 @transaction (upload_file_wiring.py:83,檔頭 :73-79 記載這是 CM-1468 實測踩到的坑: 補名發生在 route 組裝點、那裡沒有 session scope,少了它會被 IdentityContext 的降級吞成 warning、 API 回 200 但暱稱恆為 null)。 → 這是「D8 收斂只做了一半」的實據:標準件已在 jedi-common,但本套件還在用自己那份。

  • 提供給別人:plugin.register / LogForwardingAdapters / LogForwardingConfig / create_blueprint / start_forwarding_chain / restart_after_fork / iter_migrations / common.settings.ForwardingConfig / domain.repository.user_name_resolver.IUserNameResolver。

  • 宣告了但零使用的 port:無(七張全在用)。

Q4 消費者

  • (a) 主專案:5 處,4 個不同的接入點——是七支裡接線面最分散的: ① <be>/core/app_factory.py:155,159-165 start_forwarding_chain(...)(啟動期掛轉發鏈, 包 try/except,:164 註解「log 轉發是旁路,掛不起來絕不可讓服務起不來」); ② <be>/main.py:230,233 restart_after_fork()(gunicorn post_fork, :227-229 註解說明 listener 與 watcher 都是執行緒、fork 不會帶過來,不重建的話該 worker 的 log 會被丟進沒人消費的佇列且設定改了永遠不生效); ③ <be>/api/log_forwarding/__init__.py:51-86 create_blueprint(...)(route 掛載, 走 REGISTERED_APPS 而非 app_factory 註冊行——七支裡唯一一支這樣接的); ④ <be>/infra/log_forwarding/forwarding_config.py:11 + user_name_resolver.py:14(adapter)。 對應 <be>/config/app_modules.py:82-83 的 "log_forwarding" 仍在清單內(未註解掉)。
  • (b) 其他 jedi- 套件:0 處。*
  • 它自己依賴:只有 jedi-common(pyproject.toml:21,floor >=0.0.30,8 處 import)。

Q5 執行期形狀

  • route:2 條 path / 3 個動作,blueprint prefix /api/1.0(api/log_forwarding_route.py:96-97): GET|PUT /log-forwarding、POST /log-forwarding/test。 🔴 守門是 per-method 而非整支 Resource 一種(:91-97 的第三欄 {"get": "read", "put": "update"}) ——讀寫分權。掛載走 _guard()(plugin.py:247,242)。
  • 背景執行緒:有,而且是七支裡唯一的 —— ① QueueListener 執行緒(common/forwarder.py:243):真正的 socket I/O 全在這裡, QueueHandler.emit() 只把 record 丟進 queue.Queue(微秒級記憶體操作)。 ② daemon watcher 執行緒(:143,408-409 threading.Thread(name="log-forwarding-watcher", daemon=True)): 每 30 秒重讀設定、必要時重掛 handler;:286 另檢查「QueueHandler 是否仍掛在當初那些 logger 上」 (因為 dictConfig 會整批換掉 handlers 清單,:342 註解)。
  • 對外 I/O:socket(UDP/TCP)+ DB —— syslog 走 stdlib SysLogHandler(forwarder.py:178-181,socket.SOCK_STREAM|SOCK_DGRAM), :190-191 特別關掉 stdlib 的 NUL 結尾(RFC 5424 沒有這個); GELF 走自寫 handler(common/gelf_handler.py:27,136-147,_send_udp / _send_tcp,TCP timeout 2.0s)。 DB:settings_reader.py 的繞 RLS 唯讀 session。
  • 快取 / Redis:無。設定靠 watcher 輪詢(30s),不用快取層。
  • 失敗語意:一律靜默吞 ——gelf_handler.py:71,163-166「emit() 內所有 socket 例外一律吞掉並降級 ——本地 log 已經寫」;_DroppingQueueHandler(forwarder.py:72-75)在佇列滿時丟棄新訊息而非阻塞; settings_reader.py:66-67 「永不拋錯:DB 未就緒、表不存在、連線失敗都回 DISABLED」。
  • 能不能單獨起成 process:能,且已實走驗證。 harness/dev_app.py 有 --migrate / --sink / --emit 三個模式 + docker-compose.yml(port 55433,避開 5432/25432),README :29-77 的 quickstart 把整條鏈打通。但服務化的實質障礙與 jedi-log 相同且更重:它的價值在於「掛在宿主的 logging 鏈上」 ——QueueHandler 必須在宿主進程內,拆出去就不叫 log 轉發了。 它天生是「每個進程內的一個零件」,不是「一個可獨立部署的服務」。

Q6 通用性

(a) 能直接用——README :6-8 的立論:「企業客戶幾乎都會問『你們的 log 能不能進我們的日誌中心/SIEM?』。 這件事每個落地版產品都會遇到,而且做法完全一樣」。

(b) 寫死 Guidant 產品知識:程式碼幾乎零命中,但有一個設計層面的耦合:

位置 情況
<mono>/.../infra/model/log_forwarding_setting.py:14 SETTINGS_SCHEMA 預設 "config" ——config 是 Guidant DB 的 schema 名,但可用環境變數覆寫(LOG_FORWARDING_SETTINGS_SCHEMA)
migrations/002-log-forwarding-grants.sql:15-19 硬編角色名 cm_app(Guidant 的應用帳號)。但 :6-9 已寫明「此處假設 cm_app,可依宿主環境調整;沒有這個角色的 consumer 可以只套 001、跳過本檔」,且 SQL 用 IF EXISTS(...) ELSE RAISE NOTICE 包住不會炸
plugin.py:125 / common/settings.py audit_logger_name 預設 "jedi_log_forwarding"、ops_logger_name 預設同名
🔴 README.md:112-120 + <be>/api/log_forwarding/__init__.py:23-30 這是真正的耦合:套件的 logger 名是 jedi_log_forwarding.*,而 jedi-common 的 dictConfig 只認得 api/app/infra/domain/common/middleware/error_handler 這 7 個名字(<mono>/jedi-common/jedi_common/logger/config_dev.py:53-95),且 disable_existing_loggers 預設 True。宿主不傳這兩個參數的後果(<be>/api/log_forwarding/__init__.py:29-30 逐條記載):audit_logger_name 不傳 → 稽核事件掉出 system_logs、也不會被轉發出去;ops_logger_name 不傳 → 轉發鏈自己的維運訊息(已啟用/掛載失敗/輪詢失敗)整個消失。主專案的解法是傳 "app.log_forwarding" 與 "common.log_forwarding"(<be>/infra/log_forwarding/forwarding_config.py:28、<be>/api/log_forwarding/__init__.py:79)——掛在 jedi-common 認得的名字底下

也就是說:這支套件要能「不靜默失效」,前提是宿主用 jedi-common 的那套 logger 命名。 一個不用 jedi-common 的產品裝上它,會得到一個「看起來裝好了、但所有維運訊息與稽核事件都不見」的狀態。 IUserNameResolver 未收斂到 jedi-common 標準件(Q3)是第二個通用性缺口。

Q7 插件完整度

項目 有/無 證據
① api 層隨包 有 jedi_log_forwarding/api/,2 條 path,per-method 守門
② register() 有 register(app, adapters, config=None, schema_extensions=None, mount_api=True, start_forwarding=True)(plugin.py:294-300)——七支裡唯一多一個 start_forwarding 旗標的;另有 create_blueprint()(:211)、build_services()(:178)、start_forwarding_chain()(:264)、restart_after_fork()(:282)
③ migrations 隨包 有,2 支 migrations/001-log-forwarding-settings.sql(建表+預設全域列,冪等)、002-log-forwarding-grants.sql(GRANT,冪等)。且 pyproject.toml:43-45 有 include 段把 .sql 打進 wheel——註解 :41-42 說明「沒有這段,wheel 裡只會有 .py,iter_migrations() 在安裝後會回空清單而毫無錯誤訊息」。另有 iter_migrations() -> List[Tuple[str, str]](plugin.py:326)讓宿主自己決定何時套
④ DI container 預設 無 宿主 wire
⑤ 獨立 harness 有,且最完整 harness/dev_app.py(--migrate / --sink / --emit)+ docker-compose.yml(55433)
⑥ 接入 README 有 281 行(七支最長),含端到端 quickstart

做到 5/5——七支裡唯一一支五件套全齊、且是唯一有 migrations 的。

Q8 糾纏對象

  • 與 jedi-common:綁在同一條 logging 鏈上,且有一條啟動順序的紅線。 ① 啟動順序紅線:<be>/core/app_factory.py:67-70 明文「🔴 位置紅線: configure_logging() 必須在 start_forwarding_chain() 之前——dictConfig 會整批替換 logger 的 handlers 清單,晚於轉發鏈就會把它掛上的 QueueHandler 丟掉」。 jedi-common 那側也記了同一條(<mono>/jedi-common/jedi_common/logger/config_logger.py:30-33)。 兩份文件互相指名對方——這是「一邊的動作直接毀掉另一邊」的關係。 ② logger 名依賴(Q6 詳述):套件的維運訊息與稽核事件能不能活,取決於 jedi-common 的 dictConfig 認不認得那個 logger 名。 ③ port 重複(Q3 詳述):IUserNameResolver 同形狀兩份,jedi-common 那份的 docstring 直接說是抄本支的。 ④ 轉發的內容就是 jedi-common 寫進 system_logs 的那些 record——同一批 record 同時走 DBLogHandler(進 DB)與 QueueHandler(送出去),是同一份資料的兩個落點。 ⑤ 本支的稽核事件格式與 jedi-common 的 audit() 是兩份同格式的實作: <mono>/jedi-log-forwarding/jedi_log_forwarding/common/audit.py:20 產出 [AUDIT:<event_name>] k=v ...,與 <mono>/jedi-common/jedi_common/utils/audit_log.py:35 的 f"[AUDIT:{event_name}] {parts}" 格式逐字相同。本支的是「預設實作,宿主可用 adapters.audit 換掉」,主專案就換成了 jedi-common 那支(<be>/api/log_forwarding/__init__.py:57,77 from jedi_common.utils.audit_log import audit → audit=audit)。 也就是說套件內那份 common/audit.py 在主專案是死碼。
  • 與 jedi-log:看起來像但不是一件事(詳見 jedi-log Q8——本支不碰 api_logs,實查零 import、零提及)。
  • 與 jedi-notification:看起來像但不是一件事(詳見 jedi-notification Q8——觸發者、資料流向、失敗語意三者皆相反)。
  • 與 jedi-iam:只有守門的執行期依賴。 兩個能力點 log-forwarding.read / log-forwarding.update 存在於 capabilities 表(DEV live 實查),由 <be>/api/log_forwarding/__init__.py:62-71 接成 jwt_required() + require_capability(...)。另外 IUserNameResolver 的宿主實作 (<be>/infra/log_forwarding/user_name_resolver.py:35)用的是 jedi_iam.UserQueryEntity ——但那是宿主 adapter 的事,套件本身零 import jedi_iam。

§9

跨套件觀察

表 1:這七支之間的 import 依賴(方向:列 → 欄,「A 依賴 B」)

A \ B common iam sys-config sys-menu notification log log-fwd
jedi-common — 0 0 0 0 0 0
jedi-iam 183 — 0 0 0(刻意斷) 0 0
jedi-system-config 9 0 — 0 0 0 0
jedi-system-menu 15 0 0 — 0 0 0
jedi-notification 3 0 0 0 — 0 0
jedi-log 11 0 0 0 0 — 0
jedi-log-forwarding 8 0 0 0 0 0 —

七支之間只有一條邊:全部指向 jedi-common。 這七支彼此零 import(實跑 grep -rn "from jedi_xxx|import jedi_xxx" 排除 tests/harness/自身,全部 0 命中)。 唯一的例外是 jedi-iam 對 jedi-notification 那條已被刻意剪斷的邊 (<mono>/jedi-iam/pyproject.toml:51-53,改走 INotifier port)。

⚠️ 但有一條反向的隱性依賴,方向與上表相反:jedi-common 的 tenant_mixin_model.py:14,22 硬編 ForeignKey("tenants.id") / ForeignKey("org_units.id") ——那是 jedi-iam 的表。jedi-common 不 import jedi-iam,但用了那個 mixin 的表在 mapper 設定期需要 jedi-iam 的表在 metadata 裡,否則炸 NoReferencedTableError。 本批中受此約束的是 jedi-system-config(system_config.py:12 繼承 mixin)。

表 2:資料層關係(FK / 表 / RLS)

套件 自有表 live 列數(DEV) 繼承 TenantScopedMixin DB 真 FK RLS policy 分區
jedi-common system_logs 663,249 ❌ 0 ❌ RANGE(act_time) 5 分區
jedi-iam 16 張(users/tenants/org_units/roles/…) users 40 / tenants 6 / capabilities 134 ❌(自己就是被指的那端) 11 條(全在自己 16 張表之間) 部分 ❌
jedi-system-config system_configs 20 ✅ 0(ORM 宣告 2 條但 DB 沒有) ✅ 4 條 ❌
jedi-system-menu system_menus 74 ❌ 0 ❌ ❌
jedi-notification 無表 — — — — —
jedi-log api_logs 116,319 ❌ 0 ❌ RANGE(act_time) 5 分區
jedi-log-forwarding config.log_forwarding_settings 1 ❌(刻意) 0 ❌(刻意) ❌

關鍵事實:這七支的表之間,一條跨套件 FK 都沒有。 DEV live 實查(pg_constraint WHERE contype='f'):

  • system_logs / api_logs / system_menus / log_forwarding_settings → 零 FK,進出都沒有。
  • system_configs → ORM 宣告了 tenant_id → tenants.id(NOT NULL)與 org_unit_id → org_units.id, 但 DB 端一條都沒建,且 tenant_id 在 DB 是 nullable(與 ORM 相反)。 對照組 bulletins 同樣掛 mixin 卻有 bulletins_tenant_fk / bulletins_org_fk 兩條真 FK ——所以這是 system_configs 這張表的個別落差,不是 mixin 的通則。
  • jedi-iam 的 11 條 FK 全部在自己 16 張表之間(users→tenants/org_units、 org_units→tenants/org_units、roles→tenants、user_roles→users/roles/tenants/org_units、 tenants→tenants、user_tenants→tenants)。
  • jedi-iam 是全產品的 FK 匯聚點:入向 FK 統計 org_units 23 條、tenants 15 條、 users 7 條、roles 4 條、ui_routes 1 條,來源橫跨 bulletin / compliance / config / devices / feedback / oscal / survey——但沒有一條來自本批其他六支。

表 3:port 供需

port 名 定義方 消費方 宿主實作
IUserNameResolver / ITenantResolver / IOrgUnitResolver + IdentityContext jedi-common(identity/) jedi-file-upload(本批外,1 支) <be>/core/upload_file_wiring.py:53
IUserNameResolver(同形狀第二份) jedi-log-forwarding(domain/repository/) 自己 <be>/infra/log_forwarding/user_name_resolver.py:19(同名 class,另一份)
INotifier jedi-iam(plugin.py:60) 自己(10 處) <be>/common/iam_ports.py:46 → 背後是 jedi-notification
ISettingsReader jedi-iam(:71)+ jedi-system-config(:80,零使用) iam 自己 8 處 / config 0 處 <be>/common/iam_ports.py:31(只給 iam)
INotifier(第二份宣告) jedi-system-config(:90) 零使用 無
IOtpMessageBuilder / IAccountMessageBuilder jedi-iam 自己(11 / 6 處) <be>/common/iam_ports.py:65 / —
ISubTenantQuota / ILicensedModules / ITenantStorageSeeder jedi-iam(ports/tenant_provisioning.py) 自己(tenant_provisioning_service.py:98-133) <be>/core/iam_wiring.py
auth_required / capability_required / platform_admin_required sys-menu / log / notification / log-fwd 各自宣告一份 各自 route 全部接到 jedi_iam.authz
response_builder sys-menu / log / notification / log-fwd 各一份 各自 route 全部接到 jedi_common.utils.response_util.return_response

兩個明確的重複: ① IUserNameResolver 同簽名兩份(jedi-common 標準件 vs log-forwarding 自訂), 宿主為此寫了兩個同名 class 的 adapter(<be>/core/upload_file_wiring.py:53 與 <be>/infra/log_forwarding/user_name_resolver.py:19),後者 docstring :62-63 自己承認形狀相同。 ② ISettingsReader / INotifier 在 jedi-iam 與 jedi-system-config 各宣告一份 (<mono>/jedi-iam/jedi_iam/plugin.py:71,60 vs <mono>/jedi-system-config/jedi_system_config/plugin.py:80,90), 且 system-config 那兩份零使用(自己在 :118-119 註明)。

四支有 route 的套件(sys-menu / log / notification / log-fwd)宣告的守門 port, 宿主端全部接到同一個地方:jedi_iam.authz。 也就是說這四支雖然靜態上不依賴 jedi-iam, 執行期沒有 jedi-iam 就沒有守門可注入(且四支都刻意無預設值,缺就拒絕掛載)。

表 4:D6 插件五件套完整度

套件 ①api層 ②register() ③migrations ④DI container ⑤harness ⑥接入README 分數
jedi-log-forwarding ✅ ✅ ✅ 2 支 ❌ ✅ 端到端 ✅ 281行 5/6
jedi-notification ✅ 1條 ✅ —(無表) ❌ ✅ 無需DB ✅ 276行 4/5
jedi-iam ✅ 39條 ✅ ❌ ❌ ✅ ✅ 244行 4/6
jedi-system-menu ✅ 5條 ✅ ❌ ❌ ❌ ✅ 169行 3/6
jedi-log ✅ 2條 ✅ ❌ ❌ ❌ ✅ 163行 3/6
jedi-system-config ❌ ✅ ❌ ❌ ✅ ✅ 173行 3/6
jedi-common ❌ ❌ ❌ ❌ ❌ ❌ 過時 0/6

沒有任何一支自帶 DI container(七支皆由宿主 wire)。 只有 jedi-log-forwarding 有 migrations——其餘六支的建表全在主專案 <be>/scripts/sql/。

表 5:執行期形狀(能否服務化的判斷材料)

套件 route 背景 thread 對外 I/O Redis 狀態
jedi-common 0 ❌(但 import 期起 OTel exporter → localhost:4317) 檔案(log/app.log) + OTLP gRPC 空殼 純 library,不可服務化
jedi-iam 39 ❌ LDAP / Google OAuth / Cloudflare HTTP / 檔案 ✅ 自己一份 harness 已驗可獨立起
jedi-system-config 0 ❌ 無 ❌ harness 能起但無端點可打
jedi-system-menu 5 ❌ 無 ❌ 無 harness
jedi-notification 1 ❌ SMTP / Discord HTTP / Telegram HTTP ❌ 無表無狀態,最易服務化
jedi-log 2 ❌ 記憶體內 xlsx/zip ❌ 無 harness;寫入端在宿主 middleware,拆出去每請求多一次網路往返
jedi-log-forwarding 2 ✅ 2 條(QueueListener + daemon watcher) UDP/TCP socket + DB ❌ harness 端到端已驗;但 QueueHandler 必須在宿主進程內,天生是零件不是服務

🔴 決策者指定題:這七支中,哪幾支是「BE 啟動時就必須載入,否則起不來或登不進去」?

判準分三級。證據為 <be>/core/app_factory.py 與 <be>/main.py 的實際載入順序、 以及 <be>/config/app_modules.py 的 fail-fast 註記。

A 級——拔掉就起不來(import 期硬相依,無 try/except):2 支

套件 證據 拔掉的症狀
jedi-common <be>/core/app_factory.py:17 from jedi_common.handler.handler import register_error_handlers(模組層 import,第 17 行)、:28 from jedi_common.session.database.db import init_db、:71-73 configure_logging()、:131 init_database(db_uri)、:230 import jedi_common.session.database.db_mw、:343 session_scope ImportError,服務根本 import 不進來。 沒有它就沒有 DB engine、沒有 session、沒有 @transaction、沒有 error handler、沒有 logging
jedi-iam <be>/core/app_factory.py:23 → common/middleware/jwt_mw.py:19-20 from jedi_iam.middleware.jwt_mw import init_jwt_middleware(模組層);:95 init_jwt_middleware(jwt);:298-311 register_identity(..., mount_api=True);:438,440 configure_socket_identity(...);:581 from jedi_iam.common.exception.mfa_exception import OtpResendTooFrequentError;<be>/common/authz/{platform,capability,admin,signed_token,decorators}.py 全是 re-export shim ImportError(jwt_mw 在第 23 行就 import 它)。 即使勉強過了 import,register_identity(mount_api=True) 是「身分 API 存在與否的總開關」(:290-291 註解)——拔掉則 39 條身分 API 全消失,含 /login,等於登不進去;且全站授權守門(common.authz 的軸①②④⑤)全部斷

B 級——起得來但「登得進去卻不正常」/功能靜默失效:3 支

套件 證據 拔掉的症狀
jedi-system-config <be>/core/app_factory.py:144-146 from infra.system_config.runtime_config import get_runtime_config → app.config.update(get_runtime_config()),在 register_identity() 之前;註解 :141-142「必須在 init_database 之後,且在發 token 之前——JWT 效期由 flask_jwt_extended 讀 app.config」;<be>/app/auth/service/login_service.py:19,28 登入 service 建構子直接收 SystemConfigDomainService;<be>/config/app_modules.py:19 "system_config" 仍在 fail-fast 清單內 登入路徑會炸(login_service 的 DI 相依解不開),且 JWT 效期/登入鎖定次數/密碼政策全部退回編譯內建預設。註解 :143 明說「DB 不可用時回內建預設,不影響啟動」——但那是「DB 讀不到」的降級,不是「套件不在」的降級;套件不在是 ImportError
jedi-notification <be>/core/app_factory.py:373,375 register_notification(...)(模組層 import,無 try/except);:301-311 register_identity 收 container.notification_container.notification_service() 作為 INotifier 來源 ImportError(:373 沒包 try)。即使繞過,OTP 信與帳號通知信寄不出去——MFA 開啟時等於登不進去(<be>/common/iam_ports.py:91-92 的降級是「產碼但不寄信」)
jedi-log <be>/core/app_factory.py:259 init_app_interceptor(app) → <be>/common/middleware/app_mw.py:5-8 模組層 import 4 個 jedi_api_log 符號;:419,421 register_api_log(...) ImportError(app_mw.py 是模組層 import,且 app_factory.py:21 在檔頭就 import 它)。這支是 B 級裡最硬的——它的寫入端焊在每個請求的 before/after_request 上

C 級——起得來、也登得進去,只是少功能:2 支

套件 證據 拔掉的症狀
jedi-system-menu <be>/core/app_factory.py:388,390 register_system_menu(..., mount_api=True)(模組層 import,技術上仍會 ImportError,但這是接線寫法問題不是功能相依)。:381-384 註解自述:「這一行就是『選單 API 存在與否』的總開關……選單資料本身不受影響(表還在、別的模組若直接用 SystemMenuService 也照常),消失的只有 HTTP 端點」 5 條選單 API 404。前端下拉會空,但登入與核心流程不受影響
jedi-log-forwarding <be>/core/app_factory.py:155-165 是七支裡唯一被 try/except 包住的——:163-165 except Exception: logger.warning("log 轉發初始化失敗,本機 log 不受影響"),:164 註解「log 轉發是旁路,掛不起來絕不可讓服務起不來」。route 走 REGISTERED_APPS(<be>/config/app_modules.py:82-83)。<be>/main.py:230-235 的 restart_after_fork() 也包 try 唯一一支「設計上就允許失敗」的。 拔掉:轉發停止、2 條設定 API 404,其餘全部照常

結論摘要(回答決策者的「系統開起來一定要有的基本功能」):

  • 絕對必要(起不來 / 登不進去):jedi-common + jedi-iam。 這兩支是名副其實的 system-infra 核心 ——一支是地基(session/交易/例外/logging),一支是身分與授權(沒有它連 /login 都不存在)。
  • 實質必要(起得來但不可用):jedi-system-config + jedi-notification + jedi-log。 三支都是模組層硬 import、無 try/except,且各自卡在登入鏈上的一環 (config 供 JWT 效期與密碼政策、notification 供 OTP 信、log 焊在每請求的 middleware)。
  • 可選(少功能但系統健全):jedi-system-menu + jedi-log-forwarding。 其中只有 jedi-log-forwarding 在程式碼層面被明確設計成「可以失敗」(唯一有 try/except 的)。

⚠️ 一個必須指出的觀察:上述「A/B/C 級」的技術判定,有一部分是接線寫法造成的而非真實功能相依。 七支中有六支在 app_factory.py 是裸 import 無 try/except,所以任何一支不在, 服務都會 ImportError——包含 C 級的 jedi-system-menu。真正按「功能是否必要」分級的話, 只有 jedi-common / jedi-iam / jedi-system-config 三支是「沒有它就登不進去」, 其餘四支拔掉後系統仍可登入運作(notification 要看 MFA 是否強制開啟、 log 只是不再記錄存取軌跡、menu 只是下拉空、log-forwarding 本來就是旁路)。

補充觀察:七支的「產品洩漏」分佈(Q6 彙總)

套件 程式碼層洩漏 嚴重度
jedi-system-config "SMTP" / "THIRD_PARTY_LOGIN" / value["secret"] / changePwd 共 14 處硬編,散在 app 與 domain 兩層 + 一個叫 update_smtp_config() 的公開 method 最高——通用設定表認得特定產品的設定內容
jedi-common gen_comment.py:8 OPENAI_API_KEY + runtime 硬掛 openai>=2.8.0(每個 consumer 被迫裝);custom_formatter.py:27,35 OTLP endpoint 硬編 localhost:4317 且 import 期就啟動;db.py:102 「最上層租戶=平台管理者」的租戶模型判定;config_dev.py:53-95 8 個 logger 名硬編 高——地基層的洩漏影響全生態
jedi-log 匯出檔名 user_log_ + 匯出無表頭(to_excel(header=False))+ 「後端強制 created_at desc」的 FE 補償 中
jedi-log-forwarding logger 名必須掛在 jedi-common 認得的名字底下才不靜默失效;cm_app 角色名(已做 IF EXISTS 防護);schema 預設 config(可覆寫) 中(是與 jedi-common 的耦合,不是與產品的)
jedi-iam authz/error_code.py 的 GRC_* 前綴(凍結於 FE i18n 相容) 低
jedi-system-menu 程式碼零洩漏;洩漏全在資料層(AUDIT_METHOD / ssp_party_role / COMPLIANCE_FRAMEWORK_PUBLISH_STATUS 等 group 名) 最低
jedi-notification 程式碼零洩漏(grep 命中全是解釋「什麼不在疆界內」的註解) 最低

未查證事項(誠實列出)

  1. STG / POC 的 schema 是否與 DEV 一致——本次只查 DEV(guidant_ai_dev)。 system_configs 的「ORM 宣告 FK 但 DB 沒有」這個落差,未確認在 STG/POC 是否相同。
  2. api_logs.duration 的 Float vs numeric 型別落差是否造成過實際問題——只確認宣告不符,未查歷史 issue。
  3. jedi-common 的 OTel exporter(localhost:4317)在生產環境的實際行為—— 未確認 exporter 連不上時是否有重試風暴或記憶體累積(只確認它是 import 期無條件啟動)。
  4. jedi-notification README 說的「12 個消費點」——只驗證了主專案 14 處直接 import 與 app_factory 註解列出的 6 個模組名,未逐一追到 12 個確切呼叫點。
  5. 各套件的測試實際能否跑起來——只讀 pyproject 與檔案清單,未實跑任何一支的 pytest。
  6. login_logs 表在 DEV 的實際列數——只確認四個檔案的 docstring 標記它是死鏈,未查表內是否有殘留資料。