本卡屬 FR-182(母卡 FR-182 母案(同清單上一張)),卡 L1,批 1。只改 license_center repo。依賴:無。
主系統每個租戶要有一張簽過章的授權照才能用,Agent 沒有——拿到註冊 token 就能裝、裝幾台都行。決策者定調 Agent 要比照主系統拿照:License Center(簽發站,獨立 repo 獨立 DB)簽一張「Agent 照」、給客戶一組序號、客戶在 Agent 主控台看到機器碼、把序號+機器碼交回來、員工在 LC 開通簽出綁那台機器的照、寄回給客戶上傳。Agent 照跟主系統的照是不同的一張(記上層主系統照號當對照),沒有模組清單(Agent 不分功能,只分有照/沒照),到期政策只有「到期前幾天提醒、到期後寬限幾天」兩個數字。這張卡是 LC 端的地基:新開一個模組 agent_licensing(自己一張表,不碰主系統照那張表),簽發/開通/換綁/補發/展延六支純函式,照格式用既有簽章引擎加一種型別 agent_license,加五支 CLI 保底。順便把「序號產生/效期」與「私鑰解鎖」這兩段跟主系統照無關的邏輯從 licensing 模組抽到共用層,新模組才不用跨模組 import(LC 鐵律③)。也先把後面兩張卡要用的三個 blueprint 空殼建好、掛進 app.py——app.py 是大家都要往裡加一行的登記檔,由本卡一次掛完,L2/L3 只填自己的檔。
extend:序號三支(ACTIVATION_CODE_TTL/new_activation_code/is_activation_code_expired)從 modules/licensing/service.py 抽到 core/activation_code.py,licensing 改 import(行為不變,既有測試要綠);web/views/issuance.py 的 _resolve_passphrase/_unlock_or_flash_redirect 搬到 web/unlock_helper.py,issuance 改 import;core/crypto/engine.py 加 PAYLOAD_TYPE_AGENT_LICENSE = "agent_license";core/status.py 加 compute_agent_status(expires_at, notify_days_before, grace_days)(四態 valid/notice/grace/expired,不改 compute_status);core/schema.py 加 AgentLicense dataclass(與 License 並列不繼承)。new:modules/agent_licensing/{models,repository,service}.py——AgentLicenseIssuance 表(欄位見 design §4.3)、repo(insert/get_by_id/get_by_activation_code/list_current/list_by_batch/mark_activated/mark_rebind_invalidated/count_by_customer)、service 六支(issue_agent_licenses(count)/activate_agent_license/rebind_agent_license/reissue_agent_license/extend_agent_license/verify),形狀照 licensing;Alembic revision;migrations/env.py 加 import;cli/agent_commands.py 五支(agent-issue/agent-activate/agent-verify/agent-extend/agent-rebind),cli/main.py 一行 cli.add_command。登記型 owner:建空殼 web/views/agent_licenses.py(只有 bp = Blueprint)與 web/views/agent_activation_api.py、web/views/internal_agent_api.py(空 Blueprint+空 schema 不掛路由),web/app.py 三行掛上(機器兩支走 api.register_blueprint+exempt_machine_apis 加進去)——L2/L3 只填檔不碰 app.py。DEV 套 migration(.env 指 license_center_dev)
repo:~/Projects/Billows/Audit-Manager/license_center
改既有:src/license_center/core/crypto/engine.py
改既有:src/license_center/core/schema.py
改既有:src/license_center/core/status.py
改既有:src/license_center/modules/licensing/service.py(改 import 序號三支)
改既有:src/license_center/web/views/issuance.py(改 import 解鎖兩支)
改既有:src/license_center/web/views/activation.py(改 import is_activation_code_expired)
改既有:src/license_center/cli/main.py(add_command 一行)
改既有:src/license_center/web/app.py(掛三個 blueprint)
改既有:src/license_center/web/csrf.py(exempt 加兩個)
改既有:migrations/env.py
新開:src/license_center/core/activation_code.py
新開:src/license_center/web/unlock_helper.py
新開:src/license_center/modules/agent_licensing/__init__.py
新開:src/license_center/modules/agent_licensing/models.py
新開:src/license_center/modules/agent_licensing/repository.py
新開:src/license_center/modules/agent_licensing/service.py
新開:src/license_center/web/views/agent_licenses.py(空殼)
新開:src/license_center/web/views/agent_activation_api.py(空殼)
新開:src/license_center/web/views/internal_agent_api.py(空殼)
新開:src/license_center/cli/agent_commands.py
新開:migrations/versions/<rev>_agent_licensing_agent_license_issuance.py
新開:tests/test_agent_license_service.py
新開:tests/test_agent_status.py
新開:tests/test_activation_code_shared.py
CLAUDE.md(五條鐵律、不導 DDD、API schema 屬入口層、log 規範);modules/licensing/{models,repository,service}.py 整支(新模組照這個形狀長);core/crypto/engine.py 檔頭與 sign_payload/sign_update_ticket(payload_type 怎麼分型);core/status.py;tests/test_architecture_boundaries.py(會自動掃新模組)。core/activation_code.py(新):把 modules/licensing/service.py 的 ACTIVATION_CODE_TTL、new_activation_code()、is_activation_code_expired(record)、ActivationCodeExpiredError 原封搬過來;licensing service.py 改 from license_center.core.activation_code import …,web/views/activation.py、web/views/issuance.py、cli/main.py 若直接 import 這幾個名字也改。既有 tests/test_activation_code_lifecycle.py 要原樣綠(搬家不改行為)。web/unlock_helper.py(新):web/views/issuance.py 的 _resolve_passphrase()、_unlock_or_flash_redirect() 搬過來改成 public 名(resolve_passphrase/unlock_or_flash_redirect),issuance 改 import。行為不變。core/crypto/engine.py:加 PAYLOAD_TYPE_AGENT_LICENSE = "agent_license"(放在 PAYLOAD_TYPE_UPDATE_TICKET 旁,檔頭型別表補一列)。不動 sign_payload/verify_payload。core/schema.py:加 AgentExpiryPolicy(notify_days_before=30, grace_days=14) 與 AgentLicense dataclass(欄位:license_id/customer_code/order_no/issued_to/parent_license_id: str|None/type: LicenseType(只准 formal/trial,extension 拒)/issuer/issued_at/starts_at/expires_at/expiry_policy: AgentExpiryPolicy/machine_fingerprint: str|None/product_major=1/kid),to_payload() 第一鍵 payload_type,from_payload()。與 License 並列、不繼承(欄位差太多,繼承會帶進 tenant_id/plan/modules)。core/status.py:加 AGENT_EXPIRED = "expired"、compute_agent_status(expires_at, policy: AgentExpiryPolicy, now=None) -> ComputedStatus:到期前 > notify 天 → VALID;≤ notify → NOTICE;過期 ≤ grace → GRACE;超過 → expired。不改 compute_status。STATUS_LABELS 加 expired: "已過期"。modules/agent_licensing/models.py:AgentLicenseIssuance,欄位照 design.md §4.3(agent_license_id unique、batch_id 索引、customer_code 索引、parent_license_id 純字串無 FK、expiry_policy jsonb、序號三欄、machine_fingerprint/activated_at/rebind_history、issuer/event_type/license_payload/created_at);TZ_TIMESTAMP 照 licensing;每欄 comment=。索引名用 idx_* 顯式命名(licensing 檔頭有說為什麼)。modules/agent_licensing/repository.py:AgentLicenseIssuanceRepository(session)——insert、get_by_id、get_by_activation_code(只認未作廢)、get_if_current(同客戶同 parent 下最新一筆才是現行;照 licensing 的 current 判法)、list_current(customer_code=None)、list_by_batch、list_events(agent_license_id)、count_by_customer → {activated, pending}、mark_activated、mark_rebind_invalidated、revoke_activation_code。不 commit(鐵律④)。modules/agent_licensing/service.py:六支純函式,依賴傳入(repo、private_key):issue_agent_licenses(... count, ...) -> list[AgentIssueResult](batch_id = "ab-" + 日期 + 序;每張 new_agent_license_id() = "alic-" + token_hex(6);序號走 core.activation_code;count 1~50 否則 ValueError)、activate_agent_license(找序號 → activated_at 已有 raise AgentLicenseAlreadyActivatedError → 序號逾期 → 照 expires_at 已過 raise → 補指紋重簽新 id、event_type=activate、mark_activated;例外訊息序號用 activation_code_digest)、rebind_agent_license、reissue_agent_license(未開通的補發:新序號、舊序號 revoke_activation_code;已開通的:重出同一張)、extend_agent_license(基準 max(原到期, now),照 licensing _extension_base 抽成共用或複用)、verify_agent_license(signed, public_keys) -> AgentLicense(verify_payload(expected_type=PAYLOAD_TYPE_AGENT_LICENSE))。poetry run alembic revision -m "agent_licensing: agent_license_issuance"(手寫或 autogenerate 後逐行審,特別盯沒有意外 DROP);migrations/env.py 加 import license_center.modules.agent_licensing.models。.env 指 license_center_dev(188:25432)→ alembic upgrade head → alembic check 無漂移。只套 DEV。cli/agent_commands.py(新):click group agent?不——照既有扁平風格直接五個 command:agent-issue(--customer-code --order-no --issued-to --parent-license-id --type --days --count --notify-days --grace-days --issuer --output-dir,每張一檔 <id>.license+印序號表)、agent-activate <code> --fingerprint --issuer --output、agent-verify <file>(印 payload 明文+狀態)、agent-extend <id> --days --issuer、agent-rebind <id> --fingerprint --issuer。私鑰載入用 main.py 既有 _load_private_key_interactive(搬到 cli/common.py 兩邊用,或 import——別複製)。cli/main.py 只加 from license_center.cli import agent_commands; agent_commands.register(cli) 一行。web/views/agent_licenses.py(bp = Blueprint("agent_licenses", __name__, url_prefix="/agent-licenses") 掛 login_required,無路由)、web/views/agent_activation_api.py(flask-smorest Blueprint("agent_activation", __name__, url_prefix="/api/activation/agent"),無路由)、web/views/internal_agent_api.py(url_prefix="/api/internal/agent",無路由)。web/app.py:app.register_blueprint(agent_licenses.bp)、api.register_blueprint(agent_activation_api.bp)、api.register_blueprint(internal_agent_api.bp);exempt_machine_apis(csrf, …, agent_activation_api.bp, internal_agent_api.bp)。起服務確認不炸、/api/openapi.json 仍產得出來。