Compare commits

...
Author SHA1 Message Date
BrunoGao 1ae6eb6416 feat(health): stage Contabo production migration (#360)
Adds a manual-only Contabo canary path, Traefik overlays, fail-closed migration gates, and contract tests.

Refs lingjingwanxiang/ljwx-health#390
Refs lingjingwanxiang/ljwx-health#395
2026-08-02 17:20:07 +08:00
BrunoGao 6833e1253a feat(ops): prefer cross-zone health replicas (#359)
Add zone-first and hostname-fallback soft scheduling preferences plus a deploy-gate contract test.

Refs https://github.com/lingjingwanxiang/ljwx-health/issues/390
Refs https://github.com/lingjingwanxiang/ljwx-health/issues/395
2026-08-02 09:17:13 +08:00
BrunoGao 94806b6876 Remove temporary LJWX Health legacy cleanup resources (#358)
Remove the successful one-shot cleanup hook and its ordinary least-privilege RBAC so Argo prunes the last temporary resources while preserving the audit evidence.
2026-08-01 05:15:05 +08:00
BrunoGao 96db8706b5 Delete retired LJWX Health hook orphans through GitOps (#357)
Run an exact-name, least-privilege PostSync deletion for the hook objects that Argo does not ordinary-prune. Keep temporary cleanup RBAC tracked for a final GitOps prune after observed deletion.
2026-08-01 05:05:03 +08:00
BrunoGao 188cd35e41 Adopt retired LJWX Health hooks for GitOps pruning (#356)
Temporarily adopt the exact retired hook object names as empty, permissionless ordinary resources so Argo CD can track and prune them through a follow-up GitOps revision.
2026-08-01 04:59:15 +08:00
BrunoGao 2b2cd69428 Retire legacy LJWX Health PostgreSQL schema paths (#355)
Retire the duplicate PostgreSQL SQL ConfigMap/init/repair path after verified production adoption, qualified restore, and the repaired N/N-1/N compatibility window. Preserve ClickHouse SQL, immutable audit history, backups, and signed digest-only schema delivery.
2026-08-01 04:49:03 +08:00
ljwx-release-bot 6cee4f8d41 chore(ljwx-health): promote ee2e655bd137 sha256:4cb7ffe007a4 2026-07-31 20:37:31 +00:00
ljwx-release-bot 08936fd1d7 chore(ljwx-health): promote ddddd7c6f37b sha256:09a84ab83ab0 2026-07-31 20:28:46 +00:00
ljwx-release-bot 1c86d14a90 chore(ljwx-health): promote ee2e655bd137 sha256:4cb7ffe007a4 2026-07-31 20:16:42 +00:00
BrunoGao 0f7f812907 ops(health): deactivate completed restore drill (#354) 2026-08-01 03:40:53 +08:00
ljwx-release-bot cad2b157a8 chore(ljwx-health): promote ddddd7c6f37b sha256:09a84ab83ab0 2026-07-31 19:38:00 +00:00
ljwx-release-bot 989ef61e8e chore(ljwx-health): promote cfe3a8f9c773 sha256:f53e6e3f0c8e 2026-07-31 19:25:31 +00:00
BrunoGao 7345fa7342 ops(health): restore latest qualified backup (#353) 2026-08-01 03:10:11 +08:00
BrunoGao 45256de506 fix(restore): authorize signed pre-adoption plan (#352) 2026-08-01 02:53:14 +08:00
BrunoGao 9f4c1fda01 fix(restore): allow exact k3s API endpoint (#351) 2026-08-01 02:35:42 +08:00
BrunoGao 8a7e6469d0 fix(restore): prepare volume modes before ownership (#350) 2026-08-01 02:23:29 +08:00
BrunoGao 6b220a414e ops(health): run pre-adoption schema restore drill (#349) 2026-08-01 02:15:21 +08:00
BrunoGao 16d1c987cd ops(postgres): schedule post-adoption differential backup (#347) 2026-08-01 02:08:51 +08:00
BrunoGao 5772d2f3d0 feat(restore): add application-level schema restore drill (#348)
* ops(health): smoke immutable app after schema restore

* ci: silence trap-only shellcheck paths
2026-08-01 02:07:42 +08:00
ljwx-release-bot 067a6baf56 chore(ljwx-health): promote ddddd7c6f37b sha256:09a84ab83ab0 2026-07-31 17:52:24 +00:00
ljwx-release-bot bedb1058f9 chore(ljwx-health): promote cfe3a8f9c773 sha256:f53e6e3f0c8e 2026-07-31 16:23:23 +00:00
ljwx-release-bot 068ef334f5 chore(ljwx-health): promote 5797ba6d4463 sha256:3aeddea39f43 2026-07-31 15:28:09 +00:00
ljwx-release-bot 08ed4e22c6 chore(ljwx-health): promote 7566915f3417 sha256:9ef7bfe70e8b 2026-07-31 14:40:21 +00:00
ljwx-release-bot 75f392df7a chore(ljwx-health): promote d234e1c247e3 sha256:2a0284a923f2 2026-07-31 13:38:25 +00:00
ljwx-release-bot 75c0c4bad3 chore(ljwx-health): promote 3e1c56e79981 sha256:ae6b78fe2358 2026-07-31 12:27:20 +00:00
BrunoGao b236ed97ce chore(ljwx-health): activate restore drill read boundary (#346) 2026-07-31 20:02:18 +08:00
BrunoGao 7de60242c5 feat(ljwx-health): project adopted schema release facts (#345) 2026-07-31 19:48:23 +08:00
BrunoGao 8dd9827602 fix(ljwx-health): resume completed schema upgrade retries (#344)
Verify the complete signed schema before retrying adoption-plan upgrades and report exact fingerprint mismatches.
2026-07-31 18:43:21 +08:00
BrunoGao fc9dbaa9d1 fix(cluster): preserve registry-specific pull credentials (#343)
Sync Harbor and GHCR Docker credentials from distinct source Secrets, avoid client-side secret annotations, and preserve Harbor-only default ServiceAccount binding.
2026-07-31 18:33:34 +08:00
BrunoGao f35f8a4c00 fix(ljwx-health): stage signed schema release before adoption (#342)
Create the immutable signed schema Release ConfigMap at PreSync wave -3 and preserve the ordering in future promotions.
2026-07-31 11:29:41 +08:00
BrunoGao d02154f0d0 fix(ljwx-health): order schema bootstrap before PreSync job (#341)
Create bootstrap ServiceAccount and RBAC as wave -3 PreSync hooks before the wave -2 schema adoption Job.
2026-07-31 11:23:31 +08:00
ljwx-release-bot 65fac99336 chore(ljwx-health): promote 88dd0689d34a sha256:a07878a50086 2026-07-31 03:19:07 +00:00
BrunoGao 9d331f95c2 feat(ljwx-health): add isolated schema restore drill foundation (#340)
* feat(ljwx-health): add isolated schema restore drill foundation

* fix(ljwx-health): verify Cosign v3 custom bindings

* test(ljwx-health): cover production binding envelope

* fix(ljwx-health): isolate restore drill API token

* fix(ljwx-health): split restore drill cross-namespace RBAC

* fix(ljwx-health): annotate trap-only cleanup
2026-07-31 10:21:56 +08:00
BrunoGao b0a034b238 fix(schema): bootstrap migrator with signed runner image (#339)
Refs #335 and lingjingwanxiang/ljwx-health#713.

Use only the signed schema-runner image and resource-scoped Kubernetes API access for the one-time migrator credential bootstrap.
2026-07-31 09:04:38 +08:00
BrunoGao 1aa41d4c34 fix(postgres): make backup verification shell-free (#338)
Refs #335 and lingjingwanxiang/ljwx-health#713.

Replace the failed report pod with shell-free verification and preserve the successful pgBackRest recovery chain.
2026-07-31 08:52:07 +08:00
BrunoGao 3ad88f6093 ops(postgres): add Phase 5 pre-adoption backup gate (#337)
Refs #335 and lingjingwanxiang/ljwx-health#713.

Run and report the required GitOps-owned pgBackRest backup before schema adoption. The later cleanup PR removes this one-time Job after evidence is archived.
2026-07-31 08:45:03 +08:00
BrunoGao 7f8f3bc42a feat(ljwx-health): consume signed schema bundles via GitOps (#336)
Closes #335 foundation slice
Refs lingjingwanxiang/ljwx-health#713

The schema package remains dormant until a validated v2 Release receipt activates it.
2026-07-31 08:20:20 +08:00
BrunoGao c8b3d519ed docs(db): classify LJWX Health schema bundle drift (#334)
Documents canonical-source drift classification for LJWX Health phase #709.

Refs lingjingwanxiang/ljwx-health#709.
2026-07-30 21:58:43 +08:00
ljwx-release-bot 0a1e82057c chore(ljwx-health): promote d909ba7fc8a1 sha256:016ae017e2cd 2026-07-30 12:59:50 +00:00
BrunoGao 4f9185ff41 feat: enforce ljwx-health digest-only promotion (#332)
Refs #332
2026-07-30 20:21:36 +08:00
v3-lite-bot 1a4ef9a2a0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fa70c01 -> sha256:6e095b75ea62 2026-07-30 08:01:19 +00:00
v3-lite-bot 3fbe0b1e5f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@27fcb11 -> sha256:83b5947d6405 2026-07-30 07:00:33 +00:00
v3-lite-bot 7c9d0f2b84 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@faf6e6f -> sha256:d496b3284df9 2026-07-30 06:02:12 +00:00
v3-lite-bot e689ca78b0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5048b61 -> sha256:d9571ecf6c45 2026-07-30 05:09:52 +00:00
v3-lite-bot fb2cd322ad chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@75a9397 -> sha256:bf50c9e95d4c 2026-07-30 04:20:56 +00:00
v3-lite-bot ba889e2764 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c1127e7 -> sha256:a06a79d556c9 2026-07-30 03:33:06 +00:00
v3-lite-bot 4ac6141c62 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@66a8663 -> sha256:f89fe8e3ebe3 2026-07-30 02:42:37 +00:00
v3-lite-bot ed803105c0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@26b2bdd -> sha256:17f51913a188 2026-07-30 01:48:16 +00:00
v3-lite-bot 04f4aca415 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@f1480a9 -> sha256:0c46d4fcd04e 2026-07-30 00:50:31 +00:00
v3-lite-bot 2207d9bb5e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@634b0f4 -> sha256:050134f9fc33 2026-07-29 23:55:46 +00:00
v3-lite-bot dd8dfdba8b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@765097c -> sha256:979f40d2b914 2026-07-29 22:56:39 +00:00
v3-lite-bot a8b0161956 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e0e0072 -> sha256:3f71fc647300 2026-07-29 22:06:50 +00:00
v3-lite-bot 6dd3ac5f7e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@9cb90d2 -> sha256:49da0c00412e 2026-07-29 21:15:39 +00:00
v3-lite-bot d844c543b8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@6485a56 -> sha256:cc347eb33c9d 2026-07-29 20:30:26 +00:00
v3-lite-bot d5dcfa58f9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@9bdad75 -> sha256:cb38f81baa0c 2026-07-29 19:34:55 +00:00
v3-lite-bot 9b19aeb43e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@74b2c4f -> sha256:92c98d5c2acd 2026-07-29 18:50:32 +00:00
v3-lite-bot 8242c81420 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@808c6de -> sha256:e7f6bc4b240c 2026-07-29 18:04:21 +00:00
v3-lite-bot b3ed59443a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e8697cc -> sha256:0a8515bf8523 2026-07-29 17:12:54 +00:00
v3-lite-bot ad7e30d683 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@9e2d77f -> sha256:6baad7de6b55 2026-07-29 16:26:34 +00:00
v3-lite-bot d82891d8aa chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fbf0d1e -> sha256:2b1454c13a72 2026-07-29 15:31:44 +00:00
v3-lite-bot d2e0f358b6 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@cc85b02 -> sha256:e1640273bc43 2026-07-29 14:37:10 +00:00
v3-lite-bot 08496d14fb chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@9b12e85 -> sha256:05a0acba8cc8 2026-07-29 13:43:04 +00:00
v3-lite-bot 02f1dea1a7 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7fc5ccc -> sha256:70e680ddd0f6 2026-07-29 12:52:28 +00:00
v3-lite-bot d8486de967 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@dc1efe6 -> sha256:f16f51b10c2c 2026-07-29 12:03:44 +00:00
v3-lite-bot cecd98e8c2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@4054c7f -> sha256:cbaa2cb568ad 2026-07-29 11:16:29 +00:00
v3-lite-bot 9e94d56db6 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3572d8e -> sha256:517ba0f28023 2026-07-29 10:31:12 +00:00
v3-lite-bot 9e259fa1de chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0d99e60 -> sha256:a1ae4ff92e75 2026-07-29 09:42:28 +00:00
v3-lite-bot 447f224937 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3d80ba9 -> sha256:b07ab162b835 2026-07-29 08:52:23 +00:00
v3-lite-bot f04706c825 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@096dbd3 -> sha256:e283d8e12546 2026-07-29 05:41:09 +00:00
v3-lite-bot 87d41b083e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3ab4c1e -> sha256:c7c1b420200f 2026-07-29 04:59:03 +00:00
v3-lite-bot f318682ca2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@74443f1 -> sha256:97be59696d3f 2026-07-29 04:18:12 +00:00
v3-lite-bot 1834f3805b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7353209 -> sha256:9036a1046aff 2026-07-29 03:27:23 +00:00
v3-lite-bot 64fd330886 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@86d599f -> sha256:d595a32f0a55 2026-07-29 02:39:18 +00:00
v3-lite-bot 77793e4dac chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@36e7793 -> sha256:c065309adf8d 2026-07-29 02:00:48 +00:00
v3-lite-bot d677a9ec66 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@be9d80d -> sha256:3e94d721762b 2026-07-29 01:11:07 +00:00
v3-lite-bot 044f33475e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@854eacd -> sha256:a9eb4020bf95 2026-07-29 00:22:25 +00:00
v3-lite-bot 490845aa76 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@de0fd7e -> sha256:789b22f599a0 2026-07-28 23:25:23 +00:00
v3-lite-bot 79556e8bba chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7098703 -> sha256:9e3879d1ab30 2026-07-28 22:53:28 +00:00
v3-lite-bot 6e65776e26 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e856cf1 -> sha256:8aa8709ddb38 2026-07-28 22:05:36 +00:00
BrunoGao 512ada635a feat(ljwx-health): deploy blueprint operations schema (#331)
* feat(ljwx-health): deploy blueprint operations schema

* chore: retrigger deploy gate
2026-07-29 05:50:42 +08:00
v3-lite-bot 5f2ab3b635 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@394a936 -> sha256:7bd0bded6264 2026-07-28 21:10:56 +00:00
BrunoGao 6a715dd846 feat(ljwx-health): deploy IA17 tenant blueprint schema (#330)
* feat(ljwx-health): deploy tenant blueprint schema

* docs(ljwx-health): trace IA17 schema source
2026-07-29 04:22:13 +08:00
v3-lite-bot 9932d8349a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@f69fff1 -> sha256:6bb5eef238b0 2026-07-28 20:10:13 +00:00
v3-lite-bot 8e46e14168 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@84569a0 -> sha256:a7899158ede8 2026-07-28 19:11:16 +00:00
v3-lite-bot abe6e54148 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@6cc7f42 -> sha256:79f56fcdac8e 2026-07-28 18:30:30 +00:00
v3-lite-bot 547713ff17 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e620b9d -> sha256:dd8734d61f48 2026-07-28 18:09:02 +00:00
v3-lite-bot 803e2a4e28 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@59fad37 -> sha256:a94ef91d0988 2026-07-28 17:51:45 +00:00
v3-lite-bot 862f865e41 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@1d1d72c -> sha256:0d2c218227ea 2026-07-28 17:40:20 +00:00
v3-lite-bot 06327d805a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fc33a85 -> sha256:526e277ac2cc 2026-07-28 17:27:20 +00:00
v3-lite-bot c889e4ea50 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@be87c4f -> sha256:cbff807db78b 2026-07-28 17:16:10 +00:00
v3-lite-bot 21382dae97 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fc2de45 -> sha256:c7c893365317 2026-07-28 16:28:11 +00:00
BrunoGao 920ee609ac Merge pull request #329 from lingjingwanxiang/codex/issue-383-ia15-schema
feat(ljwx-health): deploy heartbeat current uniqueness
2026-07-28 23:42:47 +08:00
bruno 0fc8ca4838 feat(health): deploy heartbeat current uniqueness 2026-07-28 23:41:29 +08:00
v3-lite-bot 0b9753bad2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@103e3a5 -> sha256:73b66c747bfc 2026-07-28 15:05:59 +00:00
BrunoGao b0adb9d144 Merge pull request #328 from lingjingwanxiang/codex/issue-383-ia14-columnstore
fix(ljwx-health): tolerate columnstore envelope constraint
2026-07-28 22:47:35 +08:00
bruno eba40a0b32 fix(health): tolerate columnstore envelope constraint 2026-07-28 22:46:14 +08:00
BrunoGao 0efba2432d Merge pull request #327 from lingjingwanxiang/codex/issue-383-ia14-schema
feat(ljwx-health): deploy resource provenance envelope schema
2026-07-28 22:40:02 +08:00
bruno f3a7487eb0 feat(health): deploy resource provenance envelope schema 2026-07-28 22:36:50 +08:00
v3-lite-bot 764999867f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@599ece0 -> sha256:249aa65ceba9 2026-07-28 13:57:01 +00:00
v3-lite-bot 35ca653b65 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@133fd7a -> sha256:b55b960ffac4 2026-07-28 13:38:33 +00:00
BrunoGao fcdeb19fab feat(ljwx-health): deploy temporal device binding schema (#326)
Refs https://github.com/lingjingwanxiang/ljwx-health/issues/383
2026-07-28 21:23:46 +08:00
v3-lite-bot 37f4b9592c chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@74d42ec -> sha256:6067e11802a7 2026-07-28 12:33:37 +00:00
BrunoGao 879aafe92c feat(ljwx-health): deploy user position lifecycle schema (#325) 2026-07-28 20:14:48 +08:00
v3-lite-bot 036d1740ba chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e3a9d0e -> sha256:8a6184e0423f 2026-07-28 11:17:37 +00:00
v3-lite-bot faeff4066e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e95c064 -> sha256:ba7accd34872 2026-07-28 10:50:44 +00:00
v3-lite-bot 7723996bec chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d558dfe -> sha256:f0200e826031 2026-07-28 09:55:41 +00:00
v3-lite-bot 0b2afcfc5d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e046b34 -> sha256:7797b182ec4f 2026-07-28 09:39:59 +00:00
BrunoGao 549e2da497 fix(ljwx-health): roll out org manager assignment schema (#324) 2026-07-28 17:06:11 +08:00
v3-lite-bot fa45ca4fd5 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b12260f -> sha256:db72df18d860 2026-07-28 08:09:51 +00:00
v3-lite-bot 32aa46c7dc chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@69b64c1 -> sha256:75d1a8b3948f 2026-07-28 07:17:42 +00:00
BrunoGao 9230fe7078 fix(ljwx-health): roll out primary membership schema (#323) 2026-07-28 15:00:56 +08:00
v3-lite-bot 5a8da59f6c chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@91059b6 -> sha256:3ffd778aee5f 2026-07-28 06:00:51 +00:00
BrunoGao ec53059aa5 fix(ljwx-health): roll out health task scope (#322) 2026-07-28 13:53:33 +08:00
v3-lite-bot 3c9c6298ef chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@82445ac -> sha256:1bb619371c16 2026-07-28 05:14:40 +00:00
v3-lite-bot 3634f2a631 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@ec771f8 -> sha256:8a87c397436a 2026-07-28 04:36:43 +00:00
v3-lite-bot 1e5164b2b8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5767dac -> sha256:c62c3ce06605 2026-07-27 07:47:32 +00:00
BrunoGao 35f37c1889 fix(ljwx-health): roll out device self-service scope (#321)
Refs https://github.com/lingjingwanxiang/ljwx-health/issues/383
2026-07-27 15:42:03 +08:00
BrunoGao c7dc7bb9e7 fix(ljwx-health): roll out user feedback schema (#320)
* fix(ljwx-health): roll out user feedback schema

* test(ljwx-health): gate feedback schema rollout
2026-07-27 14:34:40 +08:00
v3-lite-bot e606a39b05 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d46c5fd -> sha256:18dc41704bb1 2026-07-27 06:24:07 +00:00
v3-lite-bot b35f862a5d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0406874 -> sha256:e42f766daf68 2026-07-27 05:24:48 +00:00
v3-lite-bot 89e69a2faf chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@ef910ed -> sha256:de071838ea1c 2026-07-27 04:36:48 +00:00
v3-lite-bot cc80ccb6b9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5425cde -> sha256:d390f9d99dab 2026-07-27 04:00:30 +00:00
v3-lite-bot abb2148262 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5b3e981 -> sha256:7a8f35933600 2026-07-27 03:25:36 +00:00
v3-lite-bot 6984e6fff1 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@8dc6d41 -> sha256:8cef06295325 2026-07-27 02:44:38 +00:00
v3-lite-bot 05880ca9d6 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d7dece3 -> sha256:c88c215d361c 2026-07-27 01:27:08 +00:00
v3-lite-bot 37e8af7a1a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@a22103e -> sha256:1be2fca1153c 2026-07-27 00:32:33 +00:00
v3-lite-bot 0ff1a7b4f9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b0b89e1 -> sha256:3470ea0aa4cd 2026-07-26 23:29:20 +00:00
v3-lite-bot 1016d04dd9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@cc0649a -> sha256:0a4dfd58da15 2026-07-26 22:44:45 +00:00
v3-lite-bot f253a06500 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c982137 -> sha256:3039b4aa2a5e 2026-07-26 21:56:32 +00:00
v3-lite-bot f7f17b9cd9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@19ff8be -> sha256:cba91f96801b 2026-07-26 21:03:14 +00:00
v3-lite-bot 7c76a83b38 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5330a2b -> sha256:2e7162600e72 2026-07-26 20:13:56 +00:00
v3-lite-bot f4a501a5e1 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b17e457 -> sha256:563727f85d58 2026-07-26 19:37:03 +00:00
v3-lite-bot da0aefe389 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@cd25554 -> sha256:9a2ff8e31788 2026-07-26 19:02:05 +00:00
v3-lite-bot 1c7f9276d0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@598161f -> sha256:fa0859cb8574 2026-07-26 18:31:11 +00:00
BrunoGao 378e359a39 fix(ljwx-health): apply external alert purpose schema (#319) 2026-07-27 01:56:11 +08:00
v3-lite-bot 259535340e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c9ad664 -> sha256:1805c9b6f67c 2026-07-26 17:42:12 +00:00
v3-lite-bot 20eb3a913c chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d81358e -> sha256:cc0c3ac15571 2026-07-26 16:47:30 +00:00
v3-lite-bot 7aea121e51 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e739c4f -> sha256:adfe9c3ef541 2026-07-26 15:33:26 +00:00
v3-lite-bot 241ece5b8d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@60b0bb1 -> sha256:95df25e788d9 2026-07-26 14:43:42 +00:00
v3-lite-bot 9ccaab082e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@029da89 -> sha256:f03dc3e6a7f3 2026-07-26 14:05:12 +00:00
v3-lite-bot cfcbf9da77 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@10e63f1 -> sha256:358a109173b0 2026-07-26 13:13:08 +00:00
v3-lite-bot d08f1ac62f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@432c0c1 -> sha256:bc80b32e93ff 2026-07-26 12:30:31 +00:00
v3-lite-bot e153ebbee0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@702660d -> sha256:0c176d54ca5e 2026-07-26 11:51:10 +00:00
v3-lite-bot eb786d85d9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c32c38a -> sha256:bbf6dc860abe 2026-07-26 11:12:37 +00:00
v3-lite-bot a1a8ccc81e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c380233 -> sha256:09775f83ce08 2026-07-26 10:33:03 +00:00
v3-lite-bot d291674d4b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@26922ca -> sha256:a6a5d1941a31 2026-07-26 09:53:45 +00:00
v3-lite-bot 8ede28e8b3 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c26d107 -> sha256:a4cb7878798c 2026-07-26 09:16:18 +00:00
v3-lite-bot d935654dda chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@607807f -> sha256:a5e385f124b7 2026-07-26 08:29:44 +00:00
v3-lite-bot 3a59f67718 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@a9daff3 -> sha256:2a023299e437 2026-07-26 07:48:32 +00:00
v3-lite-bot 028f854d48 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@93dd3fc -> sha256:8a56cbdf07fe 2026-07-26 07:09:15 +00:00
v3-lite-bot dadf08a252 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e277fd4 -> sha256:5f2bc49cb547 2026-07-26 06:28:04 +00:00
v3-lite-bot 6dd2957251 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@a5a4173 -> sha256:66dbe0ccd23c 2026-07-26 05:44:47 +00:00
v3-lite-bot 3eeff2b713 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d9341df -> sha256:11a3b7bcdef5 2026-07-26 05:05:23 +00:00
v3-lite-bot b3e1dd8874 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@02a8004 -> sha256:827596276159 2026-07-26 04:38:58 +00:00
v3-lite-bot 4ea1f5a948 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d099372 -> sha256:7b4fc1739942 2026-07-26 03:54:21 +00:00
v3-lite-bot 0e5a4de709 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0ba77a0 -> sha256:0a87b9e5f019 2026-07-26 03:10:10 +00:00
BrunoGao c7926295e9 fix(ljwx-health): roll out NO_TRAFFIC reconciliation status (#318)
Apply the five-value reconciliation status vocabulary through the K3s GitOps schema bundle and fail closed on stale or unvalidated constraints.

Refs: https://github.com/lingjingwanxiang/ljwx-health/issues/349
2026-07-26 10:29:01 +08:00
v3-lite-bot e04b3ab6b1 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0b72b8e -> sha256:da793176abe9 2026-07-26 02:18:27 +00:00
v3-lite-bot 0e3cf92b03 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fc56156 -> sha256:fc7635d319d9 2026-07-26 01:44:58 +00:00
v3-lite-bot 6cda8bb429 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@544d8e6 -> sha256:b9a41a98921a 2026-07-26 00:54:23 +00:00
v3-lite-bot 07966b7f09 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@26fcc48 -> sha256:4f7a5a1a2517 2026-07-24 06:54:46 +00:00
v3-lite-bot fd2193d593 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7062fac -> sha256:983cc34444f8 2026-07-24 06:28:47 +00:00
v3-lite-bot 60f89856e9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@14a9dfd -> sha256:2c84241d0411 2026-07-24 05:01:49 +00:00
v3-lite-bot 3257548c70 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@27fda3b -> sha256:537c1cd3e3d8 2026-07-24 04:18:04 +00:00
v3-lite-bot 2781ba1cb5 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@8b9c10d -> sha256:17e7ca00c6d7 2026-07-24 03:37:25 +00:00
v3-lite-bot 41a53da3e7 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b3cb690 -> sha256:fcdfe02b58bc 2026-07-24 02:55:40 +00:00
v3-lite-bot c8d3aee483 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7cf7024 -> sha256:4856529fee83 2026-07-24 02:08:14 +00:00
v3-lite-bot 7e51e69e5a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@a6a778e -> sha256:b9414c8a6c8c 2026-07-24 01:29:43 +00:00
v3-lite-bot efa7311dcc chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5b91272 -> sha256:eb66c9fc704e 2026-07-24 01:11:25 +00:00
v3-lite-bot 7a1b4006d0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b638bb7 -> sha256:f712d2819072 2026-07-24 00:53:42 +00:00
v3-lite-bot 578c46db63 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@28f802a -> sha256:679b9ea3f853 2026-07-24 00:34:08 +00:00
v3-lite-bot 79fb8e0e28 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@4491a54 -> sha256:0b2426c43300 2026-07-24 00:14:55 +00:00
v3-lite-bot 971d251559 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@aded320 -> sha256:6367def789bc 2026-07-23 23:53:12 +00:00
v3-lite-bot effa3ce27e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7ff94f7 -> sha256:fe1ba9967651 2026-07-23 23:32:42 +00:00
v3-lite-bot 15d3cdeac9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@04fdc0e -> sha256:45fef84baca1 2026-07-23 23:17:51 +00:00
v3-lite-bot 106416f499 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@59b426a -> sha256:abb7824a7cf0 2026-07-23 22:57:20 +00:00
v3-lite-bot 892ad95068 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@daa2db8 -> sha256:d75336af833d 2026-07-23 22:32:07 +00:00
v3-lite-bot b1d13bc483 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@453784d -> sha256:0325a9f1c266 2026-07-23 22:03:03 +00:00
v3-lite-bot bd135715be chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@484c3f1 -> sha256:e4c26e07627f 2026-07-23 21:51:29 +00:00
v3-lite-bot e9dac31682 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@47bcb0b -> sha256:202f0e0b389b 2026-07-23 21:37:21 +00:00
v3-lite-bot 71a3906451 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@852c65b -> sha256:73c4e122cf6f 2026-07-23 21:17:43 +00:00
v3-lite-bot bcc29f320d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@da58167 -> sha256:e20337850c32 2026-07-23 20:41:05 +00:00
v3-lite-bot 96d1cf5922 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@055fafe -> sha256:920149d3d811 2026-07-23 20:06:11 +00:00
v3-lite-bot e22f0b91ef chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0aaa329 -> sha256:8713662d8f5c 2026-07-23 19:30:08 +00:00
v3-lite-bot 77423cd0d2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0d6d80f -> sha256:4c711257e88e 2026-07-23 18:49:37 +00:00
v3-lite-bot 98fe72c6a6 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3c8efd2 -> sha256:50304cd2666e 2026-07-23 18:06:31 +00:00
v3-lite-bot 14fb6875a0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@638577a -> sha256:e7fb50ae803f 2026-07-23 17:40:08 +00:00
v3-lite-bot b14b349305 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e6c1383 -> sha256:c5489a846255 2026-07-23 17:20:09 +00:00
v3-lite-bot 58a314591b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c3744e6 -> sha256:458e4eaeedcc 2026-07-23 17:03:25 +00:00
v3-lite-bot bbc5c1caf5 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@ae9ca38 -> sha256:f38138a0680e 2026-07-23 16:45:40 +00:00
v3-lite-bot 7be4709589 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@8308cc6 -> sha256:9e702017828b 2026-07-23 16:29:03 +00:00
v3-lite-bot 314945fe9c chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5189ea3 -> sha256:cccef680aae8 2026-07-23 16:18:07 +00:00
v3-lite-bot 20f8248f56 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@66bea1e -> sha256:644784994f45 2026-07-23 15:54:13 +00:00
v3-lite-bot f6fbdb7cce chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@cd2f605 -> sha256:6a7517baa57c 2026-07-23 15:26:07 +00:00
v3-lite-bot 2dba97bae0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@26d67b5 -> sha256:7e6c363b3b9b 2026-07-23 14:59:03 +00:00
v3-lite-bot ae313c0081 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b522b5d -> sha256:cad05b498b4d 2026-07-23 14:42:36 +00:00
v3-lite-bot abb95b301e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@85813dc -> sha256:019f6f6a2a61 2026-07-23 14:21:48 +00:00
v3-lite-bot f890756a4d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c67259c -> sha256:adc7d314fe88 2026-07-23 14:05:58 +00:00
v3-lite-bot f69a5809b6 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b2a9a55 -> sha256:84bab36a68b6 2026-07-23 13:43:59 +00:00
v3-lite-bot 4e1280021f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c2ead4a -> sha256:b504d71ef573 2026-07-23 12:56:14 +00:00
v3-lite-bot 2b33eddb54 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@71c8512 -> sha256:6eb6413b3e07 2026-07-23 12:43:42 +00:00
v3-lite-bot 313d19e5f8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@1b418e4 -> sha256:64ad3e9f59a8 2026-07-23 12:18:09 +00:00
v3-lite-bot abce4ffd0d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@5831598 -> sha256:ec8c831f4724 2026-07-23 12:04:15 +00:00
v3-lite-bot f71a157b17 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c3c142c -> sha256:9109bad6c334 2026-07-23 11:35:03 +00:00
v3-lite-bot 0e0ec87ac9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@52c56ed -> sha256:35e00d04771e 2026-07-23 11:18:54 +00:00
v3-lite-bot a296a734a1 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@65ad7ea -> sha256:3dc6d61a285f 2026-07-23 10:59:19 +00:00
v3-lite-bot 3e44077278 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@1ce69f1 -> sha256:68f5b70a54a9 2026-07-23 10:38:42 +00:00
v3-lite-bot 288b05df27 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3b94777 -> sha256:042d6fb1afa1 2026-07-23 10:17:52 +00:00
v3-lite-bot d969b9553a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@093a5cc -> sha256:cda555a29918 2026-07-23 09:57:29 +00:00
v3-lite-bot 6f8d51c4cc chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@09e5e3c -> sha256:545d16f085c4 2026-07-23 08:16:05 +00:00
v3-lite-bot 3cd11977cc chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@645506d -> sha256:9147b8776f4d 2026-07-23 07:46:52 +00:00
v3-lite-bot f4b43e13e7 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@85675e4 -> sha256:c39169df54fd 2026-07-23 07:18:22 +00:00
v3-lite-bot ff1f5abc06 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3b9d10c -> sha256:d872c9e36723 2026-07-23 03:49:25 +00:00
v3-lite-bot 90b7d89521 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@76d5968 -> sha256:64007e050681 2026-07-23 03:23:11 +00:00
v3-lite-bot 78593a0d0f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@f6ad257 -> sha256:c56b2d0358c0 2026-07-23 03:01:57 +00:00
v3-lite-bot 47db834995 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@78e3583 -> sha256:0b4df38dd076 2026-07-23 02:44:23 +00:00
v3-lite-bot 358d1ed14b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@13bdbd6 -> sha256:9d7a8dff0679 2026-07-23 02:32:06 +00:00
v3-lite-bot 0e74cc7f5e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@4ac09f5 -> sha256:4f8cbbd1930f 2026-07-23 02:17:08 +00:00
v3-lite-bot d19d4ec83a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@59d9935 -> sha256:83d02a91d228 2026-07-23 01:47:32 +00:00
v3-lite-bot c2990b1cae chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c7645ab -> sha256:b58577bd77e7 2026-07-23 01:29:35 +00:00
v3-lite-bot 6a46f19367 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@a220f5c -> sha256:929dae563867 2026-07-23 01:14:30 +00:00
v3-lite-bot 6c15620ca8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@6eabd95 -> sha256:39c6ebdc3025 2026-07-23 00:56:49 +00:00
v3-lite-bot 83501132f8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@3e8f2ba -> sha256:75afe77ac9ce 2026-07-23 00:38:25 +00:00
v3-lite-bot e30e3a13e7 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0f8648f -> sha256:a8c02fc2b27c 2026-07-23 00:24:45 +00:00
v3-lite-bot bfb12ac4b4 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@30c729c -> sha256:c294fe8ef32c 2026-07-23 00:05:34 +00:00
v3-lite-bot f68e754ce5 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@82f0bae -> sha256:213594de5cb2 2026-07-22 07:28:50 +00:00
v3-lite-bot 9b35c5348a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@da7012f -> sha256:ce1443955a5c 2026-07-22 03:33:37 +00:00
v3-lite-bot 7a407b8d0d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@71c4996 -> sha256:691dc23376ec 2026-07-22 02:46:44 +00:00
knowledge-publisher[bot] 2f4fee1f3e feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@71c49969d7 2026-07-22 02:36:28 +00:00
BrunoGao 12470dc7c5 fix(health): make IA-05 role migration PostgreSQL 18 compatible (#317)
Refs lingjingwanxiang/ljwx-health#383
2026-07-22 10:11:26 +08:00
BrunoGao 40ade10f6f feat(health): converge tenant navigation schema (#316) 2026-07-22 10:01:32 +08:00
v3-lite-bot a30393a3bd chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@1f01e08 -> sha256:825eb9a4930e 2026-07-22 01:40:43 +00:00
knowledge-publisher[bot] ae88332b8d feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@1f01e08a30 2026-07-22 01:25:38 +00:00
knowledge-publisher[bot] cb1017c3fc feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@7b631dfa9e 2026-07-22 00:52:30 +00:00
v3-lite-bot eae432f71f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@2907d79 -> sha256:2ffd4de3e655 2026-07-22 00:50:00 +00:00
BrunoGao 8a750cb4e7 feat(ljwx-health): deploy authorization mutation grants (#315)
* feat(ljwx-health): deploy authorization mutation grants

* ci: refresh operations issue contract
2026-07-22 08:32:59 +08:00
v3-lite-bot 7593af02f9 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0eb911a -> sha256:211018cb5265 2026-07-21 23:59:18 +00:00
BrunoGao c34655931f feat(ljwx-health): deploy authorization version facts (#314) 2026-07-22 07:15:10 +08:00
v3-lite-bot 8d8c732a13 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d766ba7 -> sha256:96f482d47a4b 2026-07-21 23:13:24 +00:00
BrunoGao e4936e777a fix(health): deploy authorization relation RLS schema (#313)
* fix(health): deploy authorization relation RLS schema

* fix(health): derive relation tenant keys from owners
2026-07-22 06:25:59 +08:00
v3-lite-bot 256bf61b2b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d4ea842 -> sha256:0f5af3fa871f 2026-07-21 22:25:36 +00:00
v3-lite-bot 8acc29b489 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@8498953 -> sha256:9c178e10cde8 2026-07-19 21:59:35 +00:00
knowledge-publisher[bot] 7d414e78a1 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@84989531a2 2026-07-19 21:48:30 +00:00
v3-lite-bot 4cd8109816 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d96cf95 -> sha256:23bd4bd87d21 2026-07-19 21:35:42 +00:00
knowledge-publisher[bot] 048efeece8 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@d96cf9559b 2026-07-19 21:25:16 +00:00
v3-lite-bot 056d9b4431 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@1c5c2cd -> sha256:cd60d0ec89e6 2026-07-19 21:19:17 +00:00
knowledge-publisher[bot] be37ac7452 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@1c5c2cd0b4 2026-07-19 21:08:40 +00:00
v3-lite-bot a70913b502 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@08bcb56 -> sha256:070f5f122671 2026-07-19 20:58:54 +00:00
knowledge-publisher[bot] 314fc0d8df feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@08bcb565f8 2026-07-19 20:49:51 +00:00
BrunoGao 4f68d5e9eb fix(health): deploy mobile subject scope schema (#312) 2026-07-20 04:16:59 +08:00
v3-lite-bot 399575ad79 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fe0f875 -> sha256:8e28debce69f 2026-07-19 20:10:59 +00:00
knowledge-publisher[bot] 96a117bb8d feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@fe0f875b40 2026-07-19 20:01:32 +00:00
v3-lite-bot f6e33dba07 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c4f599d -> sha256:382b96be86ce 2026-07-19 19:35:59 +00:00
knowledge-publisher[bot] 0880a6512e feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@c4f599da4a 2026-07-19 19:26:42 +00:00
v3-lite-bot 5634fa9ed2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@2dd4349 -> sha256:33d5bdef9751 2026-07-19 19:23:50 +00:00
v3-lite-bot 35c0880463 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@035afcf -> sha256:c8d1053c1998 2026-07-19 19:12:00 +00:00
knowledge-publisher[bot] 07bd50b8dc feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@2dd434991d 2026-07-19 19:06:07 +00:00
knowledge-publisher[bot] 2dbbd32b6f feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@035afcfc16 2026-07-19 19:00:55 +00:00
knowledge-publisher[bot] b0c3f625f0 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@035afcfc16 2026-07-19 18:42:54 +00:00
v3-lite-bot b641a766cf chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@035afcf -> sha256:2fb20971a0f9 2026-07-19 18:41:44 +00:00
v3-lite-bot 5d057c9a81 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e719c15 -> sha256:038c9e564b65 2026-07-19 18:06:31 +00:00
v3-lite-bot 2736b8b610 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@fc54700 -> sha256:fada9739c7db 2026-07-19 17:54:51 +00:00
knowledge-publisher[bot] 01429bfe7c feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@e719c15d6f 2026-07-19 17:52:41 +00:00
v3-lite-bot b3d05f697d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@602c7e9 -> sha256:e907337e1df6 2026-07-19 17:45:08 +00:00
knowledge-publisher[bot] eb69037821 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@fc54700367 2026-07-19 17:40:50 +00:00
v3-lite-bot 41f4879f1e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@be5c4ac -> sha256:3317b53da1a9 2026-07-19 17:22:11 +00:00
v3-lite-bot 1f047e7969 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d6c91d1 -> sha256:9baa0e7a83c0 2026-07-19 17:11:08 +00:00
knowledge-publisher[bot] bd7751def1 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@be5c4ac666 2026-07-19 17:05:36 +00:00
v3-lite-bot 29126e1528 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@15cd3fd -> sha256:bb873674e9a3 2026-07-19 16:48:53 +00:00
v3-lite-bot ff94196547 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d4c12fb -> sha256:f38f8eba7179 2026-07-19 16:22:05 +00:00
knowledge-publisher[bot] 16f3da789a feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@d4c12fb610 2026-07-19 16:11:20 +00:00
v3-lite-bot 0d5d595801 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@c00bcd1 -> sha256:7bb41a5090a6 2026-07-19 16:06:37 +00:00
knowledge-publisher[bot] 8e9666b1f5 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@c00bcd1c2e 2026-07-19 15:57:04 +00:00
v3-lite-bot f826671af2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@4a19710 -> sha256:fc6f2256373e 2026-07-19 15:48:05 +00:00
knowledge-publisher[bot] c986f3690d feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@4a19710f99 2026-07-19 15:37:01 +00:00
v3-lite-bot 87f42bb267 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@b1d6ecb -> sha256:999b69d5c5c0 2026-07-19 15:27:19 +00:00
knowledge-publisher[bot] d06f1a8660 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@b1d6ecb2a0 2026-07-19 15:17:03 +00:00
v3-lite-bot bd3488243a chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@7ec4aaf -> sha256:ba2e1cfb0a79 2026-07-19 14:54:34 +00:00
knowledge-publisher[bot] 6801d61662 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@7ec4aaf0f1 2026-07-19 14:43:40 +00:00
v3-lite-bot 1873be591d chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@816a294 -> sha256:4bb73b243013 2026-07-19 14:04:51 +00:00
v3-lite-bot a4f2fcc330 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@06f10ba -> sha256:490e383f4c3f 2026-07-19 13:37:47 +00:00
BrunoGao 86fdcda98b fix(health): preserve underscore in RLS verification (#311) 2026-07-19 21:07:14 +08:00
BrunoGao e69c2bedbf fix(gitops): trigger health schema hooks on bundle changes (#310) 2026-07-19 21:03:20 +08:00
BrunoGao 4bf87e5ba3 fix(health): deploy platform-default RLS policy (#309) 2026-07-19 21:00:43 +08:00
v3-lite-bot 29bed96c93 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@94121fa -> sha256:8c333b325b28 2026-07-19 12:52:40 +00:00
knowledge-publisher[bot] 3370f632d5 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@f9f1cc8f7c 2026-07-19 12:52:31 +00:00
BrunoGao 44667c9ac9 fix(incident): start relay through ConfigMap symlink (#308) 2026-07-19 20:51:56 +08:00
BrunoGao 43fe777b11 feat(incident): enforce alert acknowledgement SLA (#307) 2026-07-19 20:48:46 +08:00
v3-lite-bot cba46e8bfe chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@77259c3 -> sha256:f80460b6c043 2026-07-19 12:26:19 +00:00
v3-lite-bot f0a24211c8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@552abc1 -> sha256:0ab3c5eb3755 2026-07-19 12:15:32 +00:00
knowledge-publisher[bot] b232dc926d feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@77259c3b04 2026-07-19 12:12:09 +00:00
v3-lite-bot 0845eadcca chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@1436866 -> sha256:89a88a8a1437 2026-07-19 12:04:00 +00:00
v3-lite-bot d0a2700cd1 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@4425fc6 -> sha256:8c023f25b8d8 2026-07-19 11:38:19 +00:00
knowledge-publisher[bot] 4d0ce22a45 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@6326b971f3 2026-07-19 11:01:27 +00:00
BrunoGao a6533accc0 Merge pull request #306 from lingjingwanxiang/agent/storage-capacity-alerts-375
feat(health): deploy storage capacity alerts
2026-07-19 19:00:50 +08:00
v3-lite-bot 06b81dca4e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@86947de -> sha256:4d1978bd77bf 2026-07-19 10:59:53 +00:00
bruno a35165bc34 feat(health): deploy storage capacity alerts 2026-07-19 18:54:37 +08:00
v3-lite-bot 5d070854c8 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@54e52ae -> sha256:cff98558f6db 2026-07-19 10:31:16 +00:00
BrunoGao cb4acc8686 Merge pull request #305 from lingjingwanxiang/agent/health-collection-policy-schema-373
feat(health): deploy collection policy schema
2026-07-19 17:49:30 +08:00
v3-lite-bot 1baaa020d2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0ea5874 -> sha256:f1b5009ca3cc 2026-07-19 09:47:42 +00:00
bruno 301c131ff1 feat(health): deploy collection policy schema 2026-07-19 17:40:29 +08:00
knowledge-publisher[bot] e9e6c93249 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@0ea587470e 2026-07-19 09:38:17 +00:00
BrunoGao 5b845d9c16 Merge pull request #304 from lingjingwanxiang/agent/health-shadow-init-fix-349
fix(health): make shadow datastore hooks idempotent
2026-07-19 17:32:59 +08:00
bruno 4595681367 fix(health): make shadow datastore hooks idempotent 2026-07-19 17:32:10 +08:00
BrunoGao a7113a98a4 Merge pull request #303 from lingjingwanxiang/agent/health-shadow-canary-349
feat(health): deploy governed shadow and score canaries (#349, #355)
2026-07-19 16:44:39 +08:00
bruno 251d1d2184 feat(health): canary realtime score evidence (#355) 2026-07-19 16:42:20 +08:00
bruno 20e205f7d6 feat(health): deploy governed shadow canary (#349) 2026-07-19 16:42:20 +08:00
v3-lite-bot 1485bc8c92 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@d530cd5 -> sha256:e9a770bcc393 2026-07-19 08:36:03 +00:00
knowledge-publisher[bot] c75bdbc87c feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@d530cd5415 2026-07-19 08:25:39 +00:00
v3-lite-bot ff1c4d7cb2 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@98ded89 -> sha256:ac28cc2fc32c 2026-07-19 08:11:53 +00:00
knowledge-publisher[bot] f59174c38c feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@36ed22b052 2026-07-19 07:39:02 +00:00
v3-lite-bot 2fb2998a73 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@70c04b3 -> sha256:37547c758c0e 2026-07-19 06:47:40 +00:00
knowledge-publisher[bot] 93ffb4da90 feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@70c04b3055 2026-07-19 06:38:08 +00:00
BrunoGao a66007da4e fix(ljwx-health): align MinIO runtime credentials (#302) 2026-07-19 14:17:57 +08:00
BrunoGao 0d18d908d2 fix(ljwx-health): pin pre-sync DB identity (#301) 2026-07-19 14:00:42 +08:00
BrunoGao c43fabbcf5 fix(ljwx-health): enforce restricted runtime DB role (#300) 2026-07-19 13:57:33 +08:00
v3-lite-bot 05e1c4bd6b chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@e5042a1 -> sha256:021bb31f00de 2026-07-19 05:55:49 +00:00
v3-lite-bot 071e23d92e chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@0de2cfe -> sha256:ee47dfaf7109 2026-07-19 05:31:25 +00:00
v3-lite-bot 49fb9f8ce0 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@6a4794a -> sha256:9f2ec6a2b41a 2026-07-19 05:21:01 +00:00
knowledge-publisher[bot] 3054dc6b4b feat(knowledge): ingest artifact from lingjingwanxiang/ljwx-health@6a4794a3e6 2026-07-19 05:12:21 +00:00
v3-lite-bot 8462b9e593 chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@4c3fec7 -> sha256:9b5a39a52b4f 2026-07-19 05:08:06 +00:00
v3-lite-bot c74ed25a4f chore(ljwx-health): deploy lingjingwanxiang/ljwx-health@340a6fe -> sha256:0242876131c2 2026-07-19 04:52:22 +00:00
BrunoGao e0448b0683 feat(health): add bigscreen trial observability (#299) 2026-07-19 12:23:24 +08:00
1474 changed files with 48101 additions and 6629 deletions
+28
View File
@@ -95,9 +95,37 @@ jobs:
- name: Plaintext secret scan
run: bash scripts/ci/check-no-plain-secrets.sh .changed-files.txt
- name: Validate registry pull secret source mapping
run: python3 -m unittest scripts.tests.test_registry_pull_secret_sync -v
- name: Validate Cloudflare DNS token contract
run: python3 -m unittest scripts.tests.test_cloudflare_dns_token_contract -v
- name: Validate LJWX Health alert acknowledgement relay
run: |
node --check apps/openclaw/base/alert-relay/server.mjs
node --test apps/openclaw/base/alert-relay/server.test.mjs
python3 -m py_compile \
scripts/operations/ack-alert-delivery.py \
scripts/operations/run-alert-delivery-drill.py
kubectl kustomize apps/openclaw/overlays/openclaw-dev >/tmp/openclaw-dev.yaml
- name: Validate LJWX Health failure-domain scheduling
run: |
uvx --with pyyaml python -m unittest \
scripts.tests.test_ljwx_health_failure_domain_scheduling -v
uvx --with pyyaml python -m unittest \
scripts.tests.test_ljwx_health_contabo_migration -v
- name: Validate LJWX Health schema rollout
run: |
uvx --with pyyaml python -m unittest scripts.tests.test_ljwx_health_release_promotion -v
uvx --with pyyaml python -m unittest scripts.tests.test_ljwx_health_schema_restore_drill -v
shellcheck apps/ljwx-health/schema-restore-drill/scripts/*.sh
kubectl kustomize apps/ljwx-health/overlays/k3s-eu >/tmp/ljwx-health-k3s-eu.yaml
kubectl kustomize apps/ljwx-health/overlays/contabo-prod >/tmp/ljwx-health-contabo-prod.yaml
kubectl kustomize apps/ljwx-health/overlays/contabo-prod-staging >/tmp/ljwx-health-contabo-prod-staging.yaml
- name: Detect pgvector related changes
id: pgvector
run: |
@@ -41,3 +41,25 @@ spec:
summary: "LJWX Health alert relay metrics are missing"
description: "Prometheus has not discovered alert relay metrics for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/alert-delivery.md"
- alert: LjwxHealthAlertRelayEscalationUnconfigured
expr: ljwx_alert_relay_escalation_configured == 0
for: 5m
labels:
severity: warning
category: notifications
annotations:
summary: "LJWX Health alert escalation target is not configured"
description: "The primary receiver works, but SLA-expired unread alerts cannot reach the backup on-call target."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/alert-delivery.md"
- alert: LjwxHealthAlertRelayEscalationFailure
expr: sum(increase(ljwx_alert_relay_escalations_total{result="failure"}[15m])) > 0
for: 1m
labels:
severity: critical
category: notifications
annotations:
summary: "LJWX Health alert escalation failed"
description: "At least one unread alert exceeded its acknowledgement SLA but could not be delivered to the backup on-call target."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/alert-delivery.md"
+36
View File
@@ -2,6 +2,42 @@
`ljwx-health-secret` is a runtime-only Kubernetes Secret. Values must never be committed to this repository, printed in CI, or copied into issue evidence.
## MinIO credentials
`OSS_ENDPOINT` targets `minio.infra.svc.cluster.local:9000`, so the runtime
credential must match `infra/minio-credentials`; placeholder defaults are not
valid production configuration. Synchronize and verify the two keys before a
rollout or after MinIO credential rotation:
```bash
apps/ljwx-health/base/sync-minio-credentials.sh
```
The script copies the base64 Secret fields without decoding or printing them,
merge-patches only `OSS_ACCESS_KEY` and `OSS_SECRET_KEY`, verifies exact parity,
and removes `kubectl.kubernetes.io/last-applied-configuration` from runtime
Secrets because that annotation contains plaintext-equivalent values. It does
not restart workloads; advance the pod-template revision through GitOps, wait
for both Boot replicas, then exercise an authenticated object write/read.
`create-secret.sh` also reads the same in-cluster source when accessible and no
explicit OSS environment values are supplied. It no longer supplies a
`minioadmin` fallback.
## Restricted PostgreSQL runtime role
`ljwx-boot` connects as `ljwx_app` and reads `DB_APP_PASSWORD`. Schema hooks remain isolated on `DB_MIGRATION_USER=postgres` with the existing `DB_PASSWORD`; do not point runtime traffic at that administrator credential.
Provision or rotate the application password from a protected operator shell before the GitOps cutover:
```bash
apps/ljwx-health/base/provision-db-app-password.sh
```
The script generates a value when `DB_APP_PASSWORD` is absent, sends the role DDL over stdin, verifies TCP authentication plus `NOSUPERUSER/NOBYPASSRLS`, and merges only the new Secret key. It never prints the password. Rotation does not restart workloads.
Cutover order: provision the key, reconcile the GitOps RLS policy bundle, run a restricted-role canary, merge the runtime identity change, wait for both Boot replicas, then verify `pg_stat_activity` contains `ljwx_app` and no Boot-owned `postgres` JDBC sessions. Roll back by reverting `DB_USER`/`SPRING_DATASOURCE_USERNAME` to `postgres`, both Deployment password selectors to `DB_PASSWORD`, and advancing the pod-template revision. Policy reconciliation is forward-compatible with either role and does not need to be undone.
## Sa-Token JWT signing key
The `ljwx-boot` Deployment reads key `SA_TOKEN_JWT_SECRET` as environment variable `SA_TOKEN_JWT_SECRET`. The application requires at least 32 characters and fails closed in the `k8s` profile when the key is absent.
+6
View File
@@ -60,6 +60,12 @@ spec:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: topology.kubernetes.io/zone
labelSelector:
matchLabels:
app.kubernetes.io/instance: ljwx-health-cloudflared
- weight: 50
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
+16 -2
View File
@@ -22,11 +22,18 @@ data:
DB_HOST: "postgres-lb.infra.svc.cluster.local"
DB_PORT: "5432"
DB_NAME: "ljwx_health"
DB_USER: "postgres"
# Runtime traffic must cross PostgreSQL RLS. Schema hooks keep a separate
# administrator identity and never inherit this application role.
DB_USER: "ljwx_app"
DB_MIGRATION_USER: "postgres"
# #383 IA-09 staged authorization versions. The PreSync hook creates and
# verifies all PostgreSQL facts before this flag rolls the backend forward.
AUTHORIZATION_VERSION_ENABLED: "true"
# Spring DataSource 配置(PostgreSQL)
SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres-lb.infra.svc.cluster.local:5432/ljwx_health?currentSchema=public&stringtype=unspecified"
SPRING_DATASOURCE_USERNAME: "postgres"
SPRING_DATASOURCE_USERNAME: "ljwx_app"
SPRING_DATASOURCE_DRIVER_CLASS_NAME: "org.postgresql.Driver"
# Redis 配置(连接 infra namespace)
@@ -78,3 +85,10 @@ data:
SPRING_FLYWAY_LOCATIONS: "classpath:db/migration-pg"
SPRING_FLYWAY_VALIDATE_ON_MIGRATE: "false"
SPRING_FLYWAY_CLEAN_DISABLED: "true"
# #349 governed OLAP shadow. Defaults are deliberately fail closed; the
# production canary overlay owns the one-tenant activation.
LJWX_HEALTH_OUTBOX_KAFKA_ENABLED: "false"
LJWX_HEALTH_OUTBOX_KAFKA_ALLOWED_CUSTOMER_IDS: ""
LJWX_HEALTH_OUTBOX_KAFKA_ALLOW_ALL_TENANTS: "false"
LJWX_HEALTH_OUTBOX_KAFKA_BOOTSTRAP_SERVERS: "kafka-cluster-kafka-bootstrap.shared-base.svc.cluster.local:9092"
+32 -7
View File
@@ -16,6 +16,8 @@ NC='\033[0m' # No Color
# 默认值
NAMESPACE="ljwx-health"
SECRET_NAME="ljwx-health-secret"
MINIO_SOURCE_NAMESPACE="${MINIO_SOURCE_NAMESPACE:-infra}"
MINIO_SOURCE_SECRET="${MINIO_SOURCE_SECRET:-minio-credentials}"
# 从环境变量或提示用户输入
read_secret() {
@@ -26,10 +28,10 @@ read_secret() {
if [ -n "${!var_name:-}" ]; then
echo "${!var_name}"
elif [ -n "$default_value" ]; then
read -p "$prompt [$default_value]: " value
read -r -p "$prompt [$default_value]: " value
echo "${value:-$default_value}"
else
read -p "$prompt: " value
read -r -p "$prompt: " value
echo "$value"
fi
}
@@ -37,8 +39,13 @@ read_secret() {
echo -e "${GREEN}=== LJWX Health Secret 创建工具 ===${NC}\n"
# 数据库配置
echo -e "${YELLOW}数据库配置(连接 infra namespace MySQL)${NC}"
DB_PASSWORD=$(read_secret "DB_PASSWORD" "数据库密码" "123456")
echo -e "${YELLOW}数据库配置(连接 infra namespace PostgreSQL)${NC}"
DB_PASSWORD=$(read_secret "DB_PASSWORD" "Schema 管理账号密码" "123456")
DB_APP_PASSWORD=$(read_secret "DB_APP_PASSWORD" "ljwx_app 运行时账号密码" "")
if [ -z "$DB_APP_PASSWORD" ]; then
echo -e "${RED}DB_APP_PASSWORD 不得为空;请先为 ljwx_app 配置独立密码${NC}" >&2
exit 1
fi
# Redis 配置
echo -e "\n${YELLOW}Redis 配置(连接 infra namespace Redis)${NC}"
@@ -46,8 +53,21 @@ REDIS_PASSWORD=$(read_secret "REDIS_PASSWORD" "Redis 密码" "123456")
# OSS 配置
echo -e "\n${YELLOW}OSS 配置(MinIO)${NC}"
OSS_ACCESS_KEY=$(read_secret "OSS_ACCESS_KEY" "OSS Access Key" "minioadmin")
OSS_SECRET_KEY=$(read_secret "OSS_SECRET_KEY" "OSS Secret Key" "minioadmin")
if [ -z "${OSS_ACCESS_KEY:-}" ] && [ -z "${OSS_SECRET_KEY:-}" ] \
&& kubectl -n "$MINIO_SOURCE_NAMESPACE" get secret "$MINIO_SOURCE_SECRET" >/dev/null 2>&1; then
OSS_ACCESS_KEY="$(kubectl -n "$MINIO_SOURCE_NAMESPACE" get secret "$MINIO_SOURCE_SECRET" \
-o jsonpath='{.data.root-user}' | base64 -d)"
OSS_SECRET_KEY="$(kubectl -n "$MINIO_SOURCE_NAMESPACE" get secret "$MINIO_SOURCE_SECRET" \
-o jsonpath='{.data.root-password}' | base64 -d)"
echo -e "${GREEN}已从 ${MINIO_SOURCE_NAMESPACE}/${MINIO_SOURCE_SECRET} 读取凭据(值不显示)${NC}"
else
OSS_ACCESS_KEY=$(read_secret "OSS_ACCESS_KEY" "OSS Access Key" "")
OSS_SECRET_KEY=$(read_secret "OSS_SECRET_KEY" "OSS Secret Key" "")
fi
if [ -z "$OSS_ACCESS_KEY" ] || [ -z "$OSS_SECRET_KEY" ]; then
echo -e "${RED}OSS_ACCESS_KEY 与 OSS_SECRET_KEY 不得为空${NC}" >&2
exit 1
fi
echo -e "\n${YELLOW}应用安全配置${NC}"
SECRET_KEY=$(read_secret "SECRET_KEY" "Flask SECRET_KEY(留空则自动生成)" "")
@@ -75,6 +95,7 @@ echo -e "${GREEN}创建或更新 Secret: $SECRET_NAME${NC}"
kubectl create secret generic $SECRET_NAME \
-n $NAMESPACE \
--from-literal=DB_PASSWORD="$DB_PASSWORD" \
--from-literal=DB_APP_PASSWORD="$DB_APP_PASSWORD" \
--from-literal=REDIS_PASSWORD="$REDIS_PASSWORD" \
--from-literal=OSS_ACCESS_KEY="$OSS_ACCESS_KEY" \
--from-literal=OSS_SECRET_KEY="$OSS_SECRET_KEY" \
@@ -82,6 +103,10 @@ kubectl create secret generic $SECRET_NAME \
--from-literal=SA_TOKEN_JWT_SECRET="$SA_TOKEN_JWT_SECRET" \
--dry-run=client -o yaml | kubectl apply -f -
# Remove the apply annotation because it contains base64-encoded Secret values.
kubectl -n "$NAMESPACE" annotate secret "$SECRET_NAME" \
kubectl.kubernetes.io/last-applied-configuration- >/dev/null 2>&1 || true
# 验证
echo -e "\n${GREEN}验证 Secret:${NC}"
kubectl get secret $SECRET_NAME -n $NAMESPACE
@@ -89,7 +114,7 @@ kubectl get secret $SECRET_NAME -n $NAMESPACE
echo -e "\n${GREEN}✅ Secret 创建成功!${NC}"
echo -e "\n${YELLOW}提示:${NC}"
echo " 1. Secret 包含敏感信息,请妥善保管"
echo " 2. 数据库连接地址: mysql-infra.infra.svc.cluster.local:3306"
echo " 2. 数据库连接地址: postgres-lb.infra.svc.cluster.local:5432"
echo " 3. Redis 连接地址: redis-infra.infra.svc.cluster.local:6379"
echo " 4. Argo CD 应用将通过 GitOps 自动部署"
echo " 5. 访问地址: http://health-admin.omniverseai.net (HTTP)"
@@ -37,6 +37,22 @@ spec:
- name: ghcr-pull
- name: regcred
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: topology.kubernetes.io/zone
labelSelector:
matchLabels:
app: ljwx-admin
- weight: 50
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app: ljwx-admin
containers:
- name: ljwx-admin
image: harbor.omniverseai.net/ljwx-health/ljwx-admin:latest
+19 -3
View File
@@ -36,12 +36,28 @@ spec:
prometheus.io/path: "/actuator/prometheus"
ops.ljwx.com/auth-log-policy: "sa-token-log-off-v1"
kubectl.kubernetes.io/restartedAt: "20260103-080347"
ops.ljwx.io/config-revision: "otel-http-protobuf-health-redaction-v2"
ops.ljwx.io/config-revision: "rls-restricted-role-minio-v2"
spec:
imagePullSecrets:
- name: ghcr-pull
- name: regcred
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: topology.kubernetes.io/zone
labelSelector:
matchLabels:
app: ljwx-boot
- weight: 50
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app: ljwx-boot
# 初始化容器 - 等待数据库就绪
initContainers:
- name: wait-for-db
@@ -86,14 +102,14 @@ spec:
valueFrom:
secretKeyRef:
name: ljwx-health-secret
key: DB_PASSWORD
key: DB_APP_PASSWORD
# Spring 数据库密码(新格式)
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-secret
key: DB_PASSWORD
key: DB_APP_PASSWORD
# Redis 密码(旧格式)
- name: REDIS_PASSWORD
@@ -0,0 +1,101 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: health-recon
namespace: ljwx-health
labels:
app: health-recon
component: health-shadow
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: health-recon
component: health-shadow
template:
metadata:
labels:
app: health-recon
component: health-shadow
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /actuator/prometheus
spec:
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-pull
- name: regcred
containers:
- name: health-recon
image: harbor.omniverseai.net/ljwx-health/health-recon:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
env:
- name: CLICKHOUSE_HOST
value: clickhouse.infra.svc.cluster.local
- name: CLICKHOUSE_PORT
value: "8123"
- name: CLICKHOUSE_DB
value: ljwx_health
- name: CLICKHOUSE_USER
valueFrom:
secretKeyRef:
name: ljwx-health-clickhouse-secret
key: CLICKHOUSE_USER
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-clickhouse-secret
key: CLICKHOUSE_PASSWORD
- name: PG_JDBC_URL
value: jdbc:postgresql://postgres-lb.infra.svc.cluster.local:5432/ljwx_health?connectTimeout=5&socketTimeout=30
- name: PG_USER
value: ljwx_app
- name: PG_PASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-secret
key: DB_APP_PASSWORD
- name: RECON_CRON
value: "0 30 1 * * ?"
- name: RECON_RUN_ON_START
value: "true"
- name: RECON_ALLOWED_CUSTOMER_IDS
value: "1"
- name: RECON_MAX_DIFF
value: "0"
- name: TZ
value: Asia/Shanghai
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: 300m
memory: 640Mi
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 5
failureThreshold: 24
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
terminationGracePeriodSeconds: 30
@@ -0,0 +1,93 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: health-writer
namespace: ljwx-health
labels:
app: health-writer
component: health-shadow
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: health-writer
component: health-shadow
template:
metadata:
labels:
app: health-writer
component: health-shadow
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /actuator/prometheus
spec:
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-pull
- name: regcred
containers:
- name: health-writer
image: harbor.omniverseai.net/ljwx-health/health-writer:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
env:
- name: KAFKA_BOOTSTRAP_SERVERS
value: kafka-cluster-kafka-bootstrap.shared-base.svc.cluster.local:9092
- name: KAFKA_CONCURRENCY
value: "1"
- name: CLICKHOUSE_HOST
value: clickhouse.infra.svc.cluster.local
- name: CLICKHOUSE_PORT
value: "8123"
- name: CLICKHOUSE_DB
value: ljwx_health
- name: CLICKHOUSE_USER
valueFrom:
secretKeyRef:
name: ljwx-health-clickhouse-secret
key: CLICKHOUSE_USER
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-clickhouse-secret
key: CLICKHOUSE_PASSWORD
- name: LJWX_CONSUMER_HEALTH_RAW_VENDOR_COLUMNS_ENABLED
value: "true"
- name: TZ
value: Asia/Shanghai
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 768Mi
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 5
failureThreshold: 24
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
terminationGracePeriodSeconds: 30
@@ -62,15 +62,132 @@
"targets": [{"expr": "sum(hikaricp_connections_active{namespace=\"ljwx-health\",service=\"ljwx-boot\"}) / clamp_min(sum(hikaricp_connections_max{namespace=\"ljwx-health\",service=\"ljwx-boot\"}), 1)", "legendFormat": "pool", "refId": "A"}],
"title": "Database pool saturation",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "reqps", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 13},
"id": 7,
"targets": [{"expr": "ljwx_health:bigscreen_bff_qps:5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "Bigscreen BFF QPS",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "thresholds": {"mode": "absolute", "steps": [{"color": "red"}, {"color": "green", "value": 0.995}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 13},
"id": 8,
"targets": [{"expr": "ljwx_health:bigscreen_bff_success:ratio5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "BFF success ratio (target 99.5%)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 0.5}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 13},
"id": 9,
"targets": [
{"expr": "ljwx_health:bigscreen_bff_latency_p95:5m{tenant=~\"$tenant\"}", "legendFormat": "P95 {{tenant}}", "refId": "A"},
{"expr": "ljwx_health:bigscreen_bff_latency_p99:5m{tenant=~\"$tenant\"}", "legendFormat": "P99 {{tenant}}", "refId": "B"}
],
"title": "BFF P95 / P99 latency",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "thresholds": {"mode": "absolute", "steps": [{"color": "red"}, {"color": "green", "value": 0.7}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 13},
"id": 10,
"targets": [{"expr": "ljwx_health:bigscreen_cache_hit:ratio5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "Redis cache hit ratio (target 70%)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "thresholds": {"mode": "absolute", "steps": [{"color": "red"}, {"color": "green", "value": 0.3}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 18},
"id": 11,
"targets": [{"expr": "ljwx_health:bigscreen_snapshot_304:ratio5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "Snapshot 304 ratio (target 30%)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 0.5}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 18},
"id": 12,
"targets": [{"expr": "ljwx_health:bigscreen_snapshot_build_p95:5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "Snapshot DB aggregation P95",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "thresholds": {"mode": "absolute", "steps": [{"color": "red"}, {"color": "green", "value": 0.995}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 18},
"id": 13,
"targets": [{"expr": "ljwx_health:bigscreen_kiosk_refresh_success:ratio5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "Kiosk refresh success ratio",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 2}]}}, "overrides": []},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 18},
"id": 14,
"targets": [{"expr": "ljwx_health:bigscreen_kiosk_update_delay_p95:5m{tenant=~\"$tenant\"}", "legendFormat": "tenant {{tenant}}", "refId": "A"}],
"title": "generatedAt to kiosk update P95",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 300}]}}, "overrides": []},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 23},
"id": 15,
"targets": [
{"expr": "ljwx_health:bigscreen_kiosk_heartbeat_age:seconds{tenant=~\"$tenant\"}", "legendFormat": "heartbeat age {{tenant}}", "refId": "A"},
{"expr": "ljwx_health:bigscreen_kiosk_recovery_p95:1h{tenant=~\"$tenant\"}", "legendFormat": "recovery P95 {{tenant}}", "refId": "B"}
],
"title": "Kiosk heartbeat and recovery",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "short", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 1}]}}, "overrides": []},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 23},
"id": 16,
"targets": [
{"expr": "sum by (tenant, type) (increase(bigscreen_kiosk_js_errors_total{tenant=~\"$tenant\"}[$__range]))", "legendFormat": "{{type}} {{tenant}}", "refId": "A"},
{"expr": "sum by (tenant, event) (increase(bigscreen_kiosk_connectivity_total{tenant=~\"$tenant\"}[$__range]))", "legendFormat": "{{event}} {{tenant}}", "refId": "B"}
],
"title": "JS errors and offline/recovery events",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "bytes", "thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 157286400}]}}, "overrides": []},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 23},
"id": 17,
"targets": [{"expr": "max by (tenant) (max_over_time(bigscreen_kiosk_js_heap_bytes{tenant=~\"$tenant\"}[24h])) - min by (tenant) (min_over_time(bigscreen_kiosk_js_heap_bytes{tenant=~\"$tenant\"}[24h]))", "legendFormat": "24h growth {{tenant}}", "refId": "A"}],
"title": "Kiosk 24h JS heap growth",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"gridPos": {"h": 7, "w": 24, "x": 0, "y": 30},
"id": 18,
"targets": [{"expr": "ALERTS{alertname=~\"LjwxHealthBigscreen.*\",tenant=~\"$tenant\",alertstate=\"firing\"}", "format": "table", "instant": true, "refId": "A"}],
"title": "Active bigscreen alerts",
"type": "table"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["ljwx-health", "slo", "operations"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"tags": ["ljwx-health", "slo", "operations", "bigscreen", "trial"],
"templating": {"list": [{"name": "tenant", "label": "Tenant", "type": "query", "datasource": {"type": "prometheus", "uid": "prometheus"}, "query": {"query": "label_values(bigscreen_bff_requests_total, tenant)", "refId": "StandardVariableQuery"}, "definition": "label_values(bigscreen_bff_requests_total, tenant)", "includeAll": true, "allValue": ".*", "multi": true, "refresh": 1, "current": {"text": "All", "value": "$__all", "selected": true}}]},
"time": {"from": "now-24h", "to": "now"},
"timezone": "browser",
"title": "LJWX Health / SLO and Golden Signals",
"title": "LJWX Health / SLO and Bigscreen Trial",
"uid": "ljwx-health-slo",
"version": 1
"version": 2
}
@@ -1,98 +0,0 @@
---
# =============================================================================
# Database schema verification Job
# Keep the historical resource name to avoid changing the ArgoCD hook contract.
# =============================================================================
apiVersion: batch/v1
kind: Job
metadata:
name: ljwx-health-flyway-repair
namespace: ljwx-health
labels:
app: ljwx-health
component: flyway-repair
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "1"
spec:
ttlSecondsAfterFinished: 60
backoffLimit: 1
template:
metadata:
labels:
app: ljwx-health
component: flyway-repair
spec:
restartPolicy: Never
containers:
- name: schema-verify
image: postgres:18-alpine
command:
- sh
- -ec
- |
psql \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--username="${DB_USER}" \
--dbname="${DB_NAME}" \
--set=ON_ERROR_STOP=1 <<'SQL'
DO $verify$
DECLARE
missing_objects text[] := ARRAY[]::text[];
object_name text;
BEGIN
FOREACH object_name IN ARRAY ARRAY[
'public.t_health_archive_manifest',
'public.t_health_olap_recon',
'public.t_health_realtime_state',
'public.t_health_realtime_reconciliation',
'public.v_message_effective_recipients'
] LOOP
IF to_regclass(object_name) IS NULL THEN
missing_objects := array_append(missing_objects, object_name);
END IF;
END LOOP;
IF cardinality(missing_objects) > 0 THEN
RAISE EXCEPTION 'Epic 360 schema objects missing: %', missing_objects;
END IF;
IF to_regprocedure('public.elevate_to_admin(text)') IS NULL
OR to_regprocedure('public.resolve_health_upload_identity(text)') IS NULL THEN
RAISE EXCEPTION 'Epic 360 security functions are missing';
END IF;
IF EXISTS (
SELECT 1 FROM public.sys_org_units o
WHERE o.parent_id = 0
AND COALESCE(o.is_deleted, 0) = 0
AND o.customer_id > 0
AND NOT EXISTS (
SELECT 1 FROM public.sys_org_closure c
WHERE c.ancestor_id = o.id
AND c.descendant_id = o.id
AND c.customer_id = o.customer_id
AND c.depth = 0
)
) THEN
RAISE EXCEPTION 'Tenant root self-closure repair is incomplete';
END IF;
END
$verify$;
SQL
envFrom:
- configMapRef:
name: ljwx-health-config
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-secret
key: DB_PASSWORD
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
@@ -0,0 +1,56 @@
apiVersion: batch/v1
kind: Job
metadata:
name: health-shadow-clickhouse-init
namespace: ljwx-health
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
argocd.argoproj.io/sync-wave: "-1"
spec:
backoffLimit: 3
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: OnFailure
automountServiceAccountToken: false
containers:
- name: clickhouse-schema-init
image: clickhouse/clickhouse-server:24.3.12.75
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- |
clickhouse-client \
--host clickhouse.infra.svc.cluster.local \
--port 9000 \
--user "$CLICKHOUSE_USER" \
--password "$CLICKHOUSE_PASSWORD" \
--multiquery < /schema/clickhouse-health-raw.sql
env:
- name: CLICKHOUSE_USER
valueFrom:
secretKeyRef:
name: ljwx-health-clickhouse-secret
key: CLICKHOUSE_USER
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-clickhouse-secret
key: CLICKHOUSE_PASSWORD
volumeMounts:
- name: schema
mountPath: /schema
readOnly: true
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
volumes:
- name: schema
configMap:
name: ljwx-health-shadow-clickhouse-schema
@@ -0,0 +1,73 @@
apiVersion: batch/v1
kind: Job
metadata:
name: health-shadow-kafka-init
namespace: ljwx-health
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
argocd.argoproj.io/sync-wave: "-1"
spec:
backoffLimit: 3
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: OnFailure
automountServiceAccountToken: false
containers:
- name: kafka-topic-init
image: apache/kafka:4.1.0
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- |
BOOTSTRAP=kafka-cluster-kafka-bootstrap.shared-base.svc.cluster.local:9092
/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" \
--create --if-not-exists --topic health_raw \
--partitions 3 --replication-factor 1 \
--config retention.ms=604800000
/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" \
--create --if-not-exists --topic health_raw.DLT \
--partitions 1 --replication-factor 1 \
--config retention.ms=1209600000
current_partitions="$(/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" \
--describe --topic health_raw \
| awk 'NR == 1 { for (i = 1; i <= NF; i++) if ($i == "PartitionCount:") { print $(i + 1); exit } }')"
case "$current_partitions" in
''|*[!0-9]*)
echo "unable to determine health_raw partition count: $current_partitions" >&2
exit 1
;;
esac
if [ "$current_partitions" -lt 3 ]; then
/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" \
--alter --topic health_raw --partitions 3
fi
/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--alter --entity-type topics --entity-name health_raw \
--add-config retention.ms=604800000
/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--alter --entity-type topics --entity-name health_raw.DLT \
--add-config retention.ms=1209600000
main_desc="$(/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" \
--describe --topic health_raw)"
dlt_desc="$(/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" \
--describe --topic health_raw.DLT)"
main_config="$(/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--describe --entity-type topics --entity-name health_raw)"
dlt_config="$(/opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BOOTSTRAP" \
--describe --entity-type topics --entity-name health_raw.DLT)"
printf '%s\n' "$main_desc"
printf '%s\n' "$dlt_desc"
printf '%s\n' "$main_desc" | grep -q 'PartitionCount: 3'
printf '%s\n' "$dlt_desc" | grep -q 'PartitionCount: 1'
printf '%s\n' "$main_config" | grep -q 'retention.ms=604800000'
printf '%s\n' "$dlt_config" | grep -q 'retention.ms=1209600000'
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
-69
View File
@@ -1,69 +0,0 @@
---
# =============================================================================
# Database schema rollout Job
# Runtime Flyway is intentionally disabled. This hook applies the reviewed,
# idempotent Epic #360 schema bundle before the application rollout.
# =============================================================================
apiVersion: batch/v1
kind: Job
metadata:
name: ljwx-health-init-db
namespace: ljwx-health
labels:
app: ljwx-health
component: init-db
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "0"
spec:
ttlSecondsAfterFinished: 60
backoffLimit: 1
template:
metadata:
labels:
app: ljwx-health
component: init-db
spec:
restartPolicy: Never
containers:
- name: schema-rollout
image: postgres:18-alpine
command:
- sh
- -ec
- |
for file in /schema/*.sql; do
echo "Applying ${file##*/}"
psql \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--username="${DB_USER}" \
--dbname="${DB_NAME}" \
--set=ON_ERROR_STOP=1 \
--file="${file}"
done
envFrom:
- configMapRef:
name: ljwx-health-config
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: ljwx-health-secret
key: DB_PASSWORD
volumeMounts:
- name: schema
mountPath: /schema
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
volumes:
- name: schema
configMap:
name: ljwx-health-epic-360-schema
+10 -14
View File
@@ -7,19 +7,24 @@ namespace: ljwx-health
resources:
- namespace.yaml
- configmap.yaml
- job-init-db.yaml
- job-flyway-repair.yaml
- deployment-boot.yaml
- deployment-admin.yaml
- deployment-bigscreen.yaml
- deployment-health-writer.yaml
- deployment-health-recon.yaml
- service-boot.yaml
- service-boot-compat.yaml
- service-admin.yaml
- service-bigscreen.yaml
- service-health-shadow.yaml
- job-health-shadow-kafka-init.yaml
- job-health-shadow-clickhouse-init.yaml
- ingress.yaml
- ingress-api.yaml
- servicemonitor.yaml
- prometheusrule.yaml
- prometheusrule-health-shadow.yaml
- servicemonitor-health-shadow.yaml
- pdb.yaml
- hpa.yaml
- networkpolicy.yaml
@@ -30,23 +35,14 @@ generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
- name: ljwx-health-epic-360-schema
- name: ljwx-health-shadow-clickhouse-schema
files:
- 00-health-olap-recon.sql=sql/epic-360/00-health-olap-recon.sql
- 10-rls-elevation-lease.sql=sql/epic-360/10-rls-elevation-lease.sql
- 20-health-upload-identity.sql=sql/epic-360/20-health-upload-identity.sql
- 30-health-archive-manifest.sql=sql/epic-360/30-health-archive-manifest.sql
- 40-health-olap-recon-tenant.sql=sql/epic-360/40-health-olap-recon-tenant.sql
- 50-tenant-root-closure.sql=sql/epic-360/50-tenant-root-closure.sql
- 60-user-transfer-status-permissions.sql=sql/epic-360/60-user-transfer-status-permissions.sql
- 70-health-realtime-score-state.sql=sql/epic-360/70-health-realtime-score-state.sql
- 80-message-effective-recipients.sql=sql/epic-360/80-message-effective-recipients.sql
- 90-health-archive-permissions.sql=sql/epic-360/90-health-archive-permissions.sql
- clickhouse-health-raw.sql=sql/health-shadow/clickhouse-health-raw.sql
options:
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/sync-wave: "-2"
- name: grafana-dashboard-ljwx-health-slo
files:
- ljwx-health-slo-dashboard.json=grafana/ljwx-health-slo-dashboard.json
+2
View File
@@ -11,6 +11,8 @@ spec:
values:
- ljwx-boot
- ljwx-admin
- health-writer
- health-recon
policyTypes:
- Ingress
ingress:
@@ -0,0 +1,66 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: ljwx-health-shadow
namespace: ljwx-health
labels:
release: monitoring
spec:
groups:
- name: ljwx_health_shadow
interval: 30s
rules:
- alert: LjwxHealthShadowWriterUnavailable
expr: absent(up{namespace="ljwx-health",service="health-writer"}) or up{namespace="ljwx-health",service="health-writer"} == 0
for: 5m
labels:
severity: warning
category: data-integrity
annotations:
summary: Health shadow writer is unavailable
description: PostgreSQL remains authoritative; keep publisher canary fail closed and investigate Kafka/ClickHouse.
- alert: LjwxHealthShadowReconUnavailable
expr: absent(up{namespace="ljwx-health",service="health-recon"}) or up{namespace="ljwx-health",service="health-recon"} == 0
for: 5m
labels:
severity: warning
category: data-integrity
annotations:
summary: Health shadow reconciliation is unavailable
description: No promotion decision is allowed while the recon runner is unavailable.
- alert: LjwxHealthShadowWriterFailures
expr: increase(health_shadow_writer_batches_total{result="failed"}[10m]) > 0
for: 2m
labels:
severity: warning
category: data-integrity
annotations:
summary: Health shadow writer batch failed
description: The Kafka batch remains unacknowledged; PostgreSQL upload is unaffected.
- alert: LjwxHealthShadowReconMismatch
expr: health_olap_recon_missing_count{customer_id="0"} > 0 or health_olap_recon_duplicate_count{customer_id="0"} > 0
for: 5m
labels:
severity: critical
category: data-integrity
annotations:
summary: Health shadow reconciliation mismatch
description: Missing or duplicate business rows fail the seven-day promotion gate.
- alert: LjwxHealthShadowReconLagHigh
expr: health_olap_recon_lag_seconds{customer_id="0"} > 3600
for: 10m
labels:
severity: warning
category: data-integrity
annotations:
summary: Health shadow lag exceeds one hour
description: Keep ClickHouse out of customer-visible reads until lag returns within threshold.
- alert: LjwxHealthShadowDisallowedTenant
expr: health_olap_recon_disallowed_tenant_count{customer_id="0"} > 0
for: 1m
labels:
severity: critical
category: data-integrity
annotations:
summary: Health shadow contains a non-canary tenant
description: Disable the publisher canary and investigate tenant-gate enforcement before any promotion.
+414
View File
@@ -172,6 +172,132 @@ spec:
description: "The 30m and 6h windows exceed the 6x burn threshold for the 99.9% SLO."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/service-degradation.md"
- name: ljwx_health_realtime_score_canary
interval: 30s
rules:
- record: ljwx_health:realtime_score_freshness_p95:5m
expr: |
histogram_quantile(
0.95,
sum(rate(health_realtime_score_freshness_seconds_bucket{namespace="ljwx-health",service="ljwx-boot"}[5m])) by (le)
)
- record: ljwx_health:realtime_score_write_amplification:5m
expr: |
sum(rate(health_realtime_score_flush_total{namespace="ljwx-health",service="ljwx-boot",result="success"}[5m]))
/
clamp_min(sum(rate(health_realtime_score_events_total{namespace="ljwx-health",service="ljwx-boot",result="accepted"}[5m])), 0.001)
- alert: LjwxHealthRealtimeScoreFreshnessSloBreached
expr: |
ljwx_health:realtime_score_freshness_p95:5m > 5
and
sum(rate(health_realtime_score_events_total{namespace="ljwx-health",service="ljwx-boot",result="accepted"}[5m])) > 0
for: 10m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score freshness P95 is above five seconds"
description: "Persisted events have taken more than five seconds to become visible in Redis for ten minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreListenerFailures
expr: |
sum(rate(health_realtime_score_events_total{namespace="ljwx-health",service="ljwx-boot",result="failed"}[5m])) > 0
for: 5m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score listener is failing"
description: "Persisted health events are failing in the realtime-score listener window."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreExecutorSaturated
expr: |
sum(rate(health_realtime_score_events_total{namespace="ljwx-health",service="ljwx-boot",result="rejected"}[5m])) > 0
for: 2m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score executor is rejecting work"
description: "The isolated realtime-score executor is saturated; upload ACK remains isolated and overnight ground truth is the recovery path."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreExecutorBacklog
expr: |
executor_queued_tasks{namespace="ljwx-health",service="ljwx-boot",name="healthRealtimeScoreTaskExecutor"} > 80
for: 5m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score executor queue is near capacity"
description: "The isolated executor has held more than 80 queued event batches for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreFlushFailures
expr: |
sum(rate(health_realtime_score_flush_total{namespace="ljwx-health",service="ljwx-boot",result=~"failed|ack_failed"}[5m])) > 0
for: 5m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score merge writes are failing"
description: "A realtime-score database flush or post-commit acknowledgement has failed during the last five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreWriteAmplificationHigh
expr: |
ljwx_health:realtime_score_write_amplification:5m >= 0.2
and
sum(rate(health_realtime_score_events_total{namespace="ljwx-health",service="ljwx-boot",result="accepted"}[5m])) > 0
for: 15m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score write amplification is too high"
description: "Merged PostgreSQL writes have remained at or above 20 percent of accepted events."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreCalibrationIncomplete
expr: |
health_realtime_score_reconciliation_states{namespace="ljwx-health",service="ljwx-boot"} > 0
and
health_realtime_score_reconciliation_coverage_ratio{namespace="ljwx-health",service="ljwx-boot"} < 1
for: 15m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score calibration coverage is incomplete"
description: "Tenant {{ $labels.customer }} has realtime states without matching overnight ground truth."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- alert: LjwxHealthRealtimeScoreCalibrationDeviation
expr: |
health_realtime_score_reconciliation_exceeds{namespace="ljwx-health",service="ljwx-boot"} > 0
for: 5m
labels:
severity: warning
category: health-score
service: ljwx-boot
annotations:
summary: "Realtime health-score calibration deviation exceeded the threshold"
description: "Tenant {{ $labels.customer }} has users whose realtime score differs materially from overnight ground truth."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/02-architecture/business-data-chains/epic-360-operations.md"
- name: ljwx_health_edge_connectivity
interval: 30s
rules:
@@ -255,3 +381,291 @@ spec:
summary: "LJWX Health message response P95 is above five minutes"
description: "The send-to-response P95 has remained above 300 seconds for fifteen minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/service-degradation.md"
- name: ljwx_health_storage_capacity
interval: 30s
rules:
- alert: LjwxHealthArchiveVerificationFailed
expr: sum(increase(health_archive_failed_total{namespace="ljwx-health"}[15m])) > 0
for: 1m
labels:
severity: critical
category: storage
pilot_action: stop
annotations:
summary: "Health archive verification or retention inspection failed"
description: "At least one archive or retention stage failed in the last fifteen minutes. Stop the full-collection pilot until the object and manifest are verified."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- alert: LjwxHealthArchiveDeleteBlocked
expr: sum(increase(health_archive_delete_blocked_total{namespace="ljwx-health"}[15m])) > 0
for: 1m
labels:
severity: warning
category: storage
pilot_action: hold
annotations:
summary: "Health retention deletion was blocked by the verified-manifest gate"
description: "Retention found rows without a matching VERIFIED archive manifest. Keep deletion disabled and investigate the archive range."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- alert: LjwxHealthArchiveBacklogHigh
expr: max(health_archive_pending_rows{namespace="ljwx-health"}) > 100000
for: 15m
labels:
severity: warning
category: storage
pilot_action: hold
annotations:
summary: "Health archive lifecycle backlog exceeds 100k rows"
description: "Eligible health rows have remained above the pilot capacity threshold for fifteen minutes. Do not expand the pilot."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- alert: LjwxHealthOlapLagStopThreshold
expr: max(health_olap_recon_lag_seconds{namespace="ljwx-health"}) > 300
for: 15m
labels:
severity: critical
category: storage
pilot_action: stop
annotations:
summary: "Health OLAP reconciliation lag exceeds five minutes"
description: "The RFC stop threshold has been exceeded for fifteen minutes. Disable the tenant pilot flag and return devices to the 60-second legacy policy."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- alert: LjwxHealthPostgresDeadlockDetected
expr: sum(increase(pg_stat_database_deadlocks{datname="ljwx_health"}[15m])) > 0
for: 1m
labels:
severity: critical
category: storage
pilot_action: stop
annotations:
summary: "PostgreSQL recorded a deadlock during the pilot window"
description: "Any database deadlock fails the #375 capacity gate. Stop burst collection and capture blocking-session evidence."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- alert: LjwxHealthStorageVolumeHigh
expr: |
1 - (
kubelet_volume_stats_available_bytes{namespace=~"infra|shared-base|ljwx-health",persistentvolumeclaim=~".*(postgres|timescaledb|kafka|redpanda|redis|minio|clickhouse).*"}
/
clamp_min(kubelet_volume_stats_capacity_bytes{namespace=~"infra|shared-base|ljwx-health",persistentvolumeclaim=~".*(postgres|timescaledb|kafka|redpanda|redis|minio|clickhouse).*"}, 1)
) > 0.80
for: 15m
labels:
severity: warning
category: storage
pilot_action: hold
annotations:
summary: "A health data platform volume is above 80%"
description: "{{ $labels.persistentvolumeclaim }} has crossed the expansion hold threshold."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- alert: LjwxHealthStorageVolumeCritical
expr: |
1 - (
kubelet_volume_stats_available_bytes{namespace=~"infra|shared-base|ljwx-health",persistentvolumeclaim=~".*(postgres|timescaledb|kafka|redpanda|redis|minio|clickhouse).*"}
/
clamp_min(kubelet_volume_stats_capacity_bytes{namespace=~"infra|shared-base|ljwx-health",persistentvolumeclaim=~".*(postgres|timescaledb|kafka|redpanda|redis|minio|clickhouse).*"}, 1)
) > 0.90
for: 5m
labels:
severity: critical
category: storage
pilot_action: stop
annotations:
summary: "A health data platform volume is above 90%"
description: "{{ $labels.persistentvolumeclaim }} has crossed the pilot stop threshold."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/storage-capacity-gate.md"
- name: ljwx_health_bigscreen_trial
interval: 30s
rules:
- record: ljwx_health:bigscreen_bff_qps:5m
expr: sum by (tenant) (rate(bigscreen_bff_requests_total{namespace="ljwx-health",service="ljwx-boot",endpoint=~"snapshot|realtime"}[5m]))
- record: ljwx_health:bigscreen_bff_success:ratio5m
expr: |
sum by (tenant) (rate(bigscreen_bff_requests_total{namespace="ljwx-health",service="ljwx-boot",endpoint=~"snapshot|realtime",result=~"success|not_modified"}[5m]))
/
clamp_min(sum by (tenant) (rate(bigscreen_bff_requests_total{namespace="ljwx-health",service="ljwx-boot",endpoint=~"snapshot|realtime"}[5m])), 0.001)
- record: ljwx_health:bigscreen_bff_latency_p95:5m
expr: |
histogram_quantile(
0.95,
sum by (tenant, le) (rate(bigscreen_bff_request_latency_seconds_bucket{namespace="ljwx-health",service="ljwx-boot",endpoint=~"snapshot|realtime"}[5m]))
)
- record: ljwx_health:bigscreen_bff_latency_p99:5m
expr: |
histogram_quantile(
0.99,
sum by (tenant, le) (rate(bigscreen_bff_request_latency_seconds_bucket{namespace="ljwx-health",service="ljwx-boot",endpoint=~"snapshot|realtime"}[5m]))
)
- record: ljwx_health:bigscreen_cache_hit:ratio5m
expr: |
sum by (tenant) (rate(bigscreen_snapshot_cache_access_total{namespace="ljwx-health",service="ljwx-boot",result="hit"}[5m]))
/
clamp_min(sum by (tenant) (rate(bigscreen_snapshot_cache_access_total{namespace="ljwx-health",service="ljwx-boot",result=~"hit|miss"}[5m])), 0.001)
- record: ljwx_health:bigscreen_snapshot_304:ratio5m
expr: |
sum by (tenant) (rate(bigscreen_bff_requests_total{namespace="ljwx-health",service="ljwx-boot",endpoint="snapshot",result="not_modified"}[5m]))
/
clamp_min(sum by (tenant) (rate(bigscreen_bff_requests_total{namespace="ljwx-health",service="ljwx-boot",endpoint="snapshot"}[5m])), 0.001)
- record: ljwx_health:bigscreen_snapshot_build_p95:5m
expr: |
histogram_quantile(
0.95,
sum by (tenant, le) (rate(bigscreen_snapshot_build_latency_seconds_bucket{namespace="ljwx-health",service="ljwx-boot"}[5m]))
)
- record: ljwx_health:bigscreen_kiosk_refresh_success:ratio5m
expr: |
sum by (tenant) (rate(bigscreen_kiosk_refresh_total{namespace="ljwx-health",service="ljwx-boot",result=~"success|not_modified"}[5m]))
/
clamp_min(sum by (tenant) (rate(bigscreen_kiosk_refresh_total{namespace="ljwx-health",service="ljwx-boot"}[5m])), 0.001)
- record: ljwx_health:bigscreen_kiosk_update_delay_p95:5m
expr: |
histogram_quantile(
0.95,
sum by (tenant, le) (rate(bigscreen_kiosk_last_update_delay_seconds_bucket{namespace="ljwx-health",service="ljwx-boot",result="success"}[5m]))
)
- record: ljwx_health:bigscreen_kiosk_recovery_p95:1h
expr: |
histogram_quantile(
0.95,
sum by (tenant, le) (rate(bigscreen_kiosk_recovery_latency_seconds_bucket{namespace="ljwx-health",service="ljwx-boot"}[1h]))
)
- record: ljwx_health:bigscreen_kiosk_heartbeat_age:seconds
expr: time() - max by (tenant) (bigscreen_kiosk_last_heartbeat_timestamp{namespace="ljwx-health",service="ljwx-boot"})
- alert: LjwxHealthBigscreenBffSuccessLow
expr: ljwx_health:bigscreen_bff_success:ratio5m < 0.995 and ljwx_health:bigscreen_bff_qps:5m > 0
for: 5m
labels:
severity: critical
category: bigscreen
annotations:
summary: "Bigscreen BFF success ratio is below 99.5%"
description: "Tenant {{ $labels.tenant }} has missed the trial success target for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenBffP95High
expr: ljwx_health:bigscreen_bff_latency_p95:5m > 0.5
for: 5m
labels:
severity: warning
category: bigscreen
annotations:
summary: "Bigscreen BFF P95 latency is above 500ms"
description: "Tenant {{ $labels.tenant }} has exceeded the P95 trial target for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenBffP99High
expr: ljwx_health:bigscreen_bff_latency_p99:5m > 1
for: 5m
labels:
severity: warning
category: bigscreen
annotations:
summary: "Bigscreen BFF P99 latency is above one second"
description: "Tenant {{ $labels.tenant }} has exceeded the P99 trial target for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenCacheHitLow
expr: |
ljwx_health:bigscreen_cache_hit:ratio5m < 0.70
and
sum by (tenant) (rate(bigscreen_snapshot_cache_access_total{namespace="ljwx-health",service="ljwx-boot",result=~"hit|miss"}[5m])) > 0
for: 10m
labels:
severity: warning
category: bigscreen
annotations:
summary: "Bigscreen snapshot cache hit ratio is below 70%"
description: "Tenant {{ $labels.tenant }} has missed the Redis cache hit target for ten minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenSnapshot304Low
expr: |
ljwx_health:bigscreen_snapshot_304:ratio5m < 0.30
and
sum by (tenant) (rate(bigscreen_bff_requests_total{namespace="ljwx-health",service="ljwx-boot",endpoint="snapshot"}[5m])) > 0
for: 15m
labels:
severity: warning
category: bigscreen
annotations:
summary: "Bigscreen snapshot 304 ratio is below 30%"
description: "Tenant {{ $labels.tenant }} has missed the HTTP cache reuse target for fifteen minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenKioskRefreshLow
expr: |
ljwx_health:bigscreen_kiosk_refresh_success:ratio5m < 0.995
and
sum by (tenant) (rate(bigscreen_kiosk_refresh_total{namespace="ljwx-health",service="ljwx-boot"}[5m])) > 0
for: 5m
labels:
severity: critical
category: bigscreen
annotations:
summary: "Bigscreen kiosk refresh success ratio is below 99.5%"
description: "Tenant {{ $labels.tenant }} has missed the kiosk refresh target for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenKioskUpdateDelayed
expr: ljwx_health:bigscreen_kiosk_update_delay_p95:5m > 2
for: 5m
labels:
severity: warning
category: bigscreen
annotations:
summary: "Bigscreen kiosk last-update delay is above two seconds"
description: "Tenant {{ $labels.tenant }} has exceeded the generatedAt-to-render target for five minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenKioskJsError
expr: sum by (tenant) (increase(bigscreen_kiosk_js_errors_total{namespace="ljwx-health",service="ljwx-boot"}[15m])) > 0
labels:
severity: critical
category: bigscreen
annotations:
summary: "Bigscreen kiosk reported a JavaScript error"
description: "Tenant {{ $labels.tenant }} reported at least one JavaScript error in fifteen minutes."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenKioskContinuousOffline
expr: |
ljwx_health:bigscreen_kiosk_heartbeat_age:seconds > 300
or
max by (tenant) (bigscreen_kiosk_browser_online{namespace="ljwx-health",service="ljwx-boot"}) == 0
or
absent(bigscreen_kiosk_last_heartbeat_timestamp{namespace="ljwx-health",service="ljwx-boot",tenant="1"})
for: 2m
labels:
severity: critical
category: bigscreen
annotations:
summary: "Bigscreen kiosk has been continuously offline"
description: "Tenant {{ $labels.tenant }} has no fresh online heartbeat."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
- alert: LjwxHealthBigscreenKioskRecoverySlow
expr: ljwx_health:bigscreen_kiosk_recovery_p95:1h > 30
for: 5m
labels:
severity: warning
category: bigscreen
annotations:
summary: "Bigscreen kiosk recovery P95 is above 30 seconds"
description: "Tenant {{ $labels.tenant }} has missed the offline recovery target."
runbook_url: "https://github.com/lingjingwanxiang/ljwx-health/blob/main/docs/04-operations/runbooks/bigscreen-observability.md"
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# Provision the restricted PostgreSQL runtime role without printing its secret.
set -euo pipefail
NAMESPACE="${NAMESPACE:-ljwx-health}"
SECRET_NAME="${SECRET_NAME:-ljwx-health-secret}"
POSTGRES_NAMESPACE="${POSTGRES_NAMESPACE:-infra}"
POSTGRES_POD="${POSTGRES_POD:-postgres-infra-postgresql-0}"
POSTGRES_DATABASE="${POSTGRES_DATABASE:-ljwx_health}"
APP_ROLE="${APP_ROLE:-ljwx_app}"
DB_HOST="${DB_HOST:-postgres-lb.infra.svc.cluster.local}"
DB_PORT="${DB_PORT:-5432}"
if [[ ! "${APP_ROLE}" =~ ^[a-z_][a-z0-9_]*$ ]]; then
echo "APP_ROLE must be a lowercase PostgreSQL identifier" >&2
exit 1
fi
if ! kubectl -n "${NAMESPACE}" get secret "${SECRET_NAME}" >/dev/null 2>&1; then
echo "missing secret ${NAMESPACE}/${SECRET_NAME}" >&2
exit 1
fi
app_password="${DB_APP_PASSWORD:-}"
if [[ -z "${app_password}" ]]; then
app_password="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
fi
if [[ ! "${app_password}" =~ ^[0-9A-Za-z._~-]{32,128}$ ]]; then
echo "DB_APP_PASSWORD must contain 32-128 URL-safe characters" >&2
exit 1
fi
# The generated alphabet needs no SQL escaping. Feed the DDL through stdin so
# the password does not appear in argv, terminal output, or shell history.
printf "ALTER ROLE %s LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '%s';\n" \
"${APP_ROLE}" "${app_password}" \
| kubectl -n "${POSTGRES_NAMESPACE}" exec -i "${POSTGRES_POD}" -- \
psql -U postgres -d "${POSTGRES_DATABASE}" -v ON_ERROR_STOP=1 -f - >/dev/null
# Prove TCP password authentication and role flags before publishing the key.
actual_flags="$({ printf '%s\n' "${app_password}"; } \
| kubectl -n "${POSTGRES_NAMESPACE}" exec -i "${POSTGRES_POD}" -- sh -ec '
IFS= read -r PGPASSWORD
export PGPASSWORD
psql --host="$1" --port="$2" --username="$3" --dbname="$4" \
--no-password --tuples-only --no-align \
--command="SELECT current_user || '\''|'\'' || rolsuper || '\''|'\'' || rolbypassrls FROM pg_roles WHERE rolname = current_user"
' sh "${DB_HOST}" "${DB_PORT}" "${APP_ROLE}" "${POSTGRES_DATABASE}")"
if [[ "${actual_flags}" != "${APP_ROLE}|false|false" ]]; then
echo "restricted-role verification failed" >&2
exit 1
fi
# Preserve every existing Secret key and merge only DB_APP_PASSWORD. The patch
# travels over stdin, so neither the plaintext nor base64 value appears in argv.
DB_APP_PASSWORD_B64="$(printf '%s' "${app_password}" | base64 | tr -d '\n')"
printf '{"data":{"DB_APP_PASSWORD":"%s"}}\n' "${DB_APP_PASSWORD_B64}" \
| kubectl -n "${NAMESPACE}" patch secret "${SECRET_NAME}" \
--type=merge --patch-file=/dev/stdin >/dev/null
unset DB_APP_PASSWORD_B64
unset app_password
echo "provisioned ${APP_ROLE}: LOGIN/NOSUPERUSER/NOBYPASSRLS; secret key updated without disclosure"
@@ -0,0 +1,33 @@
apiVersion: v1
kind: Service
metadata:
name: health-writer
namespace: ljwx-health
labels:
app: health-writer
component: health-shadow
spec:
selector:
app: health-writer
component: health-shadow
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: health-recon
namespace: ljwx-health
labels:
app: health-recon
component: health-shadow
spec:
selector:
app: health-recon
component: health-shadow
ports:
- name: http
port: 8080
targetPort: http
@@ -0,0 +1,28 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: ljwx-health-shadow
namespace: ljwx-health
labels:
release: monitoring
app.kubernetes.io/name: ljwx-health
app.kubernetes.io/component: health-shadow
spec:
namespaceSelector:
matchNames:
- ljwx-health
selector:
matchLabels:
component: health-shadow
endpoints:
- port: http
path: /actuator/prometheus
interval: 30s
scrapeTimeout: 10s
relabelings:
- sourceLabels: [__meta_kubernetes_namespace]
targetLabel: namespace
- sourceLabels: [__meta_kubernetes_service_name]
targetLabel: service
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: pod
@@ -1,13 +0,0 @@
-- V3.1.30: PG↔ClickHouse 对账审计表(Epic #238 M6 / issue #244)
-- consumer 侧 recon 应用按日写入;连续 7 天 diff<=阈值为旁路接管读流量的运行期验收前提。
-- 本地开发不走 Flyway:用 psql 直接应用本文件。
CREATE TABLE IF NOT EXISTS t_health_olap_recon (
recon_day DATE PRIMARY KEY,
pg_count BIGINT NOT NULL,
ch_count BIGINT, -- NULL = ClickHouse 当日不可达
diff BIGINT, -- NULL = 无法计算(CH 不可达)
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE t_health_olap_recon IS '#244 M6: PG(t_user_health_data) vs ClickHouse(t_health_raw) 日级对账审计';
@@ -1,121 +0,0 @@
-- Local direct-SQL companion for #347. Local development must not depend on Flyway.
ALTER TABLE public.sys_audit_log
ADD COLUMN IF NOT EXISTS trace_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS target_scope VARCHAR(200);
DO $block$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ljwx_app') THEN
GRANT USAGE ON SCHEMA public TO ljwx_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ljwx_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ljwx_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ljwx_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO ljwx_app;
END IF;
END
$block$;
CREATE UNLOGGED TABLE IF NOT EXISTS public.rls_elevation_lease (
backend_pid INTEGER NOT NULL,
transaction_id XID8 NOT NULL,
actor_id BIGINT NOT NULL,
target_scope VARCHAR(200) NOT NULL,
elevated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (backend_pid, transaction_id)
);
REVOKE ALL ON TABLE public.rls_elevation_lease FROM PUBLIC;
DO $block$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ljwx_app') THEN
REVOKE ALL ON TABLE public.rls_elevation_lease FROM ljwx_app;
END IF;
END
$block$;
CREATE OR REPLACE FUNCTION public.rls_elevation_active()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $function$
SELECT EXISTS (
SELECT 1
FROM public.rls_elevation_lease
WHERE backend_pid = pg_backend_pid()
AND transaction_id = pg_current_xact_id()
)
$function$;
CREATE OR REPLACE FUNCTION public.elevate_to_admin(reason TEXT)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $function$
DECLARE
actor_id_value BIGINT;
actor_name_value TEXT;
trace_id_value TEXT;
target_scope_value TEXT;
BEGIN
IF reason IS NULL OR length(btrim(reason)) = 0 THEN
RAISE EXCEPTION 'elevate_to_admin requires a non-empty reason';
END IF;
actor_id_value := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::BIGINT, -1);
actor_name_value := COALESCE(
NULLIF(current_setting('app.current_user', true), ''), 'unknown');
trace_id_value := COALESCE(
NULLIF(current_setting('app.elevation_trace_id', true), ''), 'none');
target_scope_value := COALESCE(
NULLIF(current_setting('app.elevation_target_scope', true), ''), 'ALL');
DELETE FROM public.rls_elevation_lease
WHERE backend_pid = pg_backend_pid()
OR elevated_at < NOW() - INTERVAL '1 day';
INSERT INTO public.rls_elevation_lease (
backend_pid, transaction_id, actor_id, target_scope)
VALUES (
pg_backend_pid(), pg_current_xact_id(), actor_id_value, target_scope_value)
ON CONFLICT (backend_pid, transaction_id) DO UPDATE
SET actor_id = EXCLUDED.actor_id,
target_scope = EXCLUDED.target_scope,
elevated_at = NOW();
INSERT INTO public.sys_audit_log (
user_id, user_name, action, resource, response_status,
operator, reason, trace_id, target_scope, created_at)
VALUES (
actor_id_value, actor_name_value, 'ADMIN_BYPASS', 'RLS', 'SUCCESS',
actor_name_value, btrim(reason), trace_id_value, target_scope_value, NOW());
END;
$function$;
DO $block$
BEGIN
EXECUTE format(
'ALTER FUNCTION public.rls_elevation_active() OWNER TO %I', current_user);
EXECUTE format(
'ALTER FUNCTION public.elevate_to_admin(TEXT) OWNER TO %I', current_user);
END
$block$;
REVOKE ALL ON FUNCTION public.rls_elevation_active() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.elevate_to_admin(TEXT) FROM PUBLIC;
DO $block$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ljwx_app') THEN
GRANT EXECUTE ON FUNCTION public.rls_elevation_active() TO ljwx_app;
GRANT EXECUTE ON FUNCTION public.elevate_to_admin(TEXT) TO ljwx_app;
END IF;
END
$block$;
@@ -1,43 +0,0 @@
-- Local direct-SQL companion for #353. Local development must not depend on Flyway.
CREATE OR REPLACE FUNCTION public.resolve_health_upload_identity(p_device_sn TEXT)
RETURNS TABLE(customer_id BIGINT, user_id BIGINT, org_id BIGINT)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $function$
WITH candidates AS MATERIALIZED (
SELECT du.customer_id,
du.user_id,
COALESCE(du.org_id, u.org_id) AS org_id
FROM public.t_device_user du
JOIN public.sys_user u
ON u.id = du.user_id
AND u.customer_id = du.customer_id
WHERE du.device_sn = btrim(p_device_sn)
AND COALESCE(du.is_deleted, 0) = 0
AND (upper(COALESCE(du.bind_status, '')) = 'BOUND'
OR upper(COALESCE(du.status, '')) = 'BIND')
AND COALESCE(u.is_deleted, 0) = 0
AND u.status = '1'
AND du.customer_id > 0
AND (du.org_id IS NULL OR u.org_id IS NULL OR du.org_id = u.org_id)
)
SELECT c.customer_id, c.user_id, c.org_id
FROM candidates c
WHERE (SELECT count(*) FROM candidates) = 1
$function$;
REVOKE ALL ON FUNCTION public.resolve_health_upload_identity(TEXT) FROM PUBLIC;
DO $block$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ljwx_app') THEN
GRANT EXECUTE ON FUNCTION public.resolve_health_upload_identity(TEXT) TO ljwx_app;
END IF;
END
$block$;
COMMENT ON FUNCTION public.resolve_health_upload_identity(TEXT) IS
'Returns exactly one active device binding for health upload attribution; zero rows means missing or ambiguous.';
@@ -1,43 +0,0 @@
-- Local direct-SQL companion for #348.
CREATE TABLE IF NOT EXISTS t_health_archive_manifest (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
source_table VARCHAR(100) NOT NULL,
schema_version VARCHAR(40) NOT NULL,
range_start TIMESTAMPTZ NOT NULL,
range_end TIMESTAMPTZ NOT NULL,
max_source_id BIGINT NOT NULL DEFAULT 0,
row_count BIGINT NOT NULL,
checksum VARCHAR(128),
object_uri TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_health_archive_manifest_status
CHECK (status IN ('PENDING', 'UPLOADED', 'VERIFIED', 'FAILED', 'RESTORED')),
CONSTRAINT ck_health_archive_manifest_range CHECK (range_end > range_start),
CONSTRAINT ck_health_archive_manifest_verified CHECK (
status <> 'VERIFIED'
OR (checksum IS NOT NULL AND object_uri IS NOT NULL AND verified_at IS NOT NULL))
);
ALTER TABLE t_health_archive_manifest
ADD COLUMN IF NOT EXISTS max_source_id BIGINT NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX IF NOT EXISTS uq_health_archive_manifest_object
ON t_health_archive_manifest (object_uri)
WHERE object_uri IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_health_archive_manifest_lookup
ON t_health_archive_manifest (source_table, status, range_end DESC, customer_id);
ALTER TABLE t_health_archive_manifest ALTER COLUMN customer_id SET NOT NULL;
ALTER TABLE t_health_archive_manifest ENABLE ROW LEVEL SECURITY;
ALTER TABLE t_health_archive_manifest FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON t_health_archive_manifest;
CREATE POLICY tenant_isolation ON t_health_archive_manifest
USING (public.rls_elevation_active() OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint)
WITH CHECK (public.rls_elevation_active() OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint);
GRANT SELECT, INSERT, UPDATE, DELETE ON t_health_archive_manifest TO ljwx_app;
GRANT USAGE, SELECT ON SEQUENCE t_health_archive_manifest_id_seq TO ljwx_app;
@@ -1,36 +0,0 @@
-- Local direct-SQL companion for #349.
ALTER TABLE t_health_olap_recon DROP CONSTRAINT IF EXISTS t_health_olap_recon_pkey;
ALTER TABLE t_health_olap_recon
ADD COLUMN IF NOT EXISTS customer_id BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS missing_count BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS duplicate_count BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS late_count BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS lag_seconds BIGINT,
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
ADD COLUMN IF NOT EXISTS promotion_ready BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE t_health_olap_recon
ADD CONSTRAINT t_health_olap_recon_pkey PRIMARY KEY (recon_day, customer_id);
ALTER TABLE t_health_olap_recon DROP CONSTRAINT IF EXISTS ck_health_olap_recon_status;
ALTER TABLE t_health_olap_recon
ADD CONSTRAINT ck_health_olap_recon_status
CHECK (status IN ('PENDING', 'PASS', 'FAIL', 'UNREACHABLE'));
CREATE INDEX IF NOT EXISTS idx_health_olap_recon_gate
ON t_health_olap_recon (customer_id, recon_day DESC, status);
ALTER TABLE t_health_olap_recon ENABLE ROW LEVEL SECURITY;
ALTER TABLE t_health_olap_recon FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON t_health_olap_recon;
CREATE POLICY tenant_isolation ON t_health_olap_recon
USING (
public.rls_elevation_active()
OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint
)
WITH CHECK (
public.rls_elevation_active()
OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint
);
GRANT SELECT, INSERT, UPDATE, DELETE ON t_health_olap_recon TO ljwx_app;
@@ -1,34 +0,0 @@
-- #350 local idempotent repair. Run the count queries before and after the INSERT.
SELECT COUNT(*) AS missing_root_closure_before
FROM sys_org_units o
WHERE o.parent_id = 0
AND COALESCE(o.is_deleted, 0) = 0
AND o.customer_id > 0
AND NOT EXISTS (
SELECT 1 FROM sys_org_closure c
WHERE c.ancestor_id = o.id AND c.descendant_id = o.id
AND c.customer_id = o.customer_id AND c.depth = 0);
INSERT INTO sys_org_closure (
ancestor_id, descendant_id, depth, customer_id, create_time, update_time)
SELECT o.id, o.id, 0, o.customer_id, NOW(), NOW()
FROM sys_org_units o
WHERE o.parent_id = 0
AND COALESCE(o.is_deleted, 0) = 0
AND o.customer_id > 0
AND NOT EXISTS (
SELECT 1 FROM sys_org_closure c
WHERE c.ancestor_id = o.id AND c.descendant_id = o.id
AND c.customer_id = o.customer_id AND c.depth = 0)
ON CONFLICT (ancestor_id, descendant_id, customer_id) DO NOTHING;
SELECT COUNT(*) AS missing_root_closure_after
FROM sys_org_units o
WHERE o.parent_id = 0
AND COALESCE(o.is_deleted, 0) = 0
AND o.customer_id > 0
AND NOT EXISTS (
SELECT 1 FROM sys_org_closure c
WHERE c.ancestor_id = o.id AND c.descendant_id = o.id
AND c.customer_id = o.customer_id AND c.depth = 0);
@@ -1,43 +0,0 @@
-- Local direct-SQL companion for V3.1.44.
SELECT set_config('app.current_tenant', '0', false);
WITH target_menu AS (
SELECT id, name FROM sys_menu
WHERE route_name IN ('organization_employee', 'manage_user') AND COALESCE(is_deleted, 0) = 0
ORDER BY CASE route_name WHEN 'organization_employee' THEN 0 ELSE 1 END
LIMIT 1
), desired_permissions(id, name, resource, sort) AS (
VALUES
(310230000008::bigint, '调动部门', 'sys:user:transfer', 8),
(310230000009::bigint, '用户启停', 'sys:user:status', 9)
)
INSERT INTO sys_permission (
id, menu_id, menu_name, name, resource, description, sort, status,
customer_id, create_user, create_user_id, create_time, update_user, update_user_id, update_time, is_deleted
)
SELECT p.id, m.id, m.name, p.name, p.resource, '', p.sort, '1', 0,
'system', 0, NOW(), 'system', 0, NOW(), 0
FROM desired_permissions p CROSS JOIN target_menu m
ON CONFLICT (id) DO UPDATE
SET menu_id = EXCLUDED.menu_id, menu_name = EXCLUDED.menu_name,
name = EXCLUDED.name, resource = EXCLUDED.resource, sort = EXCLUDED.sort,
status = '1', customer_id = 0, update_time = NOW(), is_deleted = 0;
WITH desired_permissions(id) AS (
VALUES (310230000008::bigint), (310230000009::bigint)
), target_roles AS (
SELECT id AS role_id FROM sys_role
WHERE COALESCE(is_deleted, 0) = 0
AND role_code IN ('PLATFORM_ADMIN', 'TENANT_ADMIN', 'ADMIN', 'tenant_admin', 'DAdmin', 'dept_manager')
), missing_grants AS (
SELECT r.role_id, p.id AS permission_id,
310230200000 + ROW_NUMBER() OVER (ORDER BY r.role_id, p.id) AS id
FROM target_roles r CROSS JOIN desired_permissions p
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_permission rp
WHERE rp.role_id = r.role_id AND rp.permission_id = p.id AND COALESCE(rp.is_deleted, 0) = 0
)
)
INSERT INTO sys_role_permission (id, role_id, permission_id, create_user, create_user_id, create_time, is_deleted)
SELECT id, role_id, permission_id, 'system', 0, NOW(), 0 FROM missing_grants
ON CONFLICT (id) DO NOTHING;
@@ -1,45 +0,0 @@
-- Local direct-SQL companion for V3.1.45.
CREATE TABLE IF NOT EXISTS t_health_realtime_state (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
business_date DATE NOT NULL,
sample_count BIGINT NOT NULL DEFAULT 0,
realtime_score NUMERIC(8,2),
profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
state_version BIGINT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_health_realtime_state UNIQUE (customer_id, user_id, business_date)
);
CREATE TABLE IF NOT EXISTS t_health_realtime_reconciliation (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
business_date DATE NOT NULL,
sample_count BIGINT NOT NULL DEFAULT 0,
realtime_score NUMERIC(8,2),
ground_truth_score NUMERIC(8,2),
absolute_deviation NUMERIC(10,4),
relative_deviation NUMERIC(10,6),
exceeds_threshold BOOLEAN NOT NULL DEFAULT FALSE,
calibrated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_health_realtime_reconciliation UNIQUE (customer_id, user_id, business_date)
);
CREATE INDEX IF NOT EXISTS idx_health_realtime_recon_tenant_date
ON t_health_realtime_reconciliation (customer_id, business_date, exceeds_threshold);
ALTER TABLE t_health_realtime_state ENABLE ROW LEVEL SECURITY;
ALTER TABLE t_health_realtime_state FORCE ROW LEVEL SECURITY;
ALTER TABLE t_health_realtime_reconciliation ENABLE ROW LEVEL SECURITY;
ALTER TABLE t_health_realtime_reconciliation FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON t_health_realtime_state;
CREATE POLICY tenant_isolation ON t_health_realtime_state
USING (public.rls_elevation_active() OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint)
WITH CHECK (public.rls_elevation_active() OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint);
DROP POLICY IF EXISTS tenant_isolation ON t_health_realtime_reconciliation;
CREATE POLICY tenant_isolation ON t_health_realtime_reconciliation
USING (public.rls_elevation_active() OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint)
WITH CHECK (public.rls_elevation_active() OR customer_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint);
GRANT SELECT, INSERT, UPDATE, DELETE ON t_health_realtime_state TO ljwx_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON t_health_realtime_reconciliation TO ljwx_app;
GRANT USAGE, SELECT ON SEQUENCE t_health_realtime_state_id_seq TO ljwx_app;
GRANT USAGE, SELECT ON SEQUENCE t_health_realtime_reconciliation_id_seq TO ljwx_app;
@@ -1,36 +0,0 @@
-- Local direct-SQL companion for V3.1.46.
CREATE OR REPLACE VIEW v_message_effective_recipients
WITH (security_invoker = true, security_barrier = true) AS
SELECT dm.customer_id, dm.id AS internal_message_id, dm.message_id AS business_message_id,
dm.fanout_mode, u.id AS user_id, u.user_name, u.org_id, u.org_name,
COALESCE(detail.response_status, 'UNREAD') AS response_status,
detail.read_time, detail.acknowledge_time, detail.process_time,
detail.read_duration_ms, detail.acknowledge_duration_ms, detail.process_duration_ms
FROM t_device_message dm
JOIN sys_user u
ON u.customer_id = dm.customer_id AND COALESCE(u.is_deleted, 0) = 0 AND u.status = '1'
AND ((NOT COALESCE(dm.is_announcement, FALSE) AND u.id = dm.user_id)
OR (COALESCE(dm.is_announcement, FALSE) AND (
upper(COALESCE(dm.audience::jsonb->>'scope', dm.target_scope, 'CUSTOMER')) IN ('ALL', 'CUSTOMER')
OR (upper(COALESCE(dm.audience::jsonb->>'scope', dm.target_scope, '')) = 'ORG'
AND ((COALESCE(dm.audience::jsonb->>'orgId', '') ~ '^[0-9]+$'
AND (dm.audience::jsonb->>'orgId')::bigint = u.org_id)
OR EXISTS (
SELECT 1 FROM jsonb_array_elements_text(
COALESCE(dm.audience::jsonb->'filters'->'org_ids', '[]'::jsonb)
) audience_org(org_id)
WHERE audience_org.org_id ~ '^[0-9]+$' AND audience_org.org_id::bigint = u.org_id
)))
OR (upper(COALESCE(dm.audience::jsonb->>'scope', dm.target_scope, '')) IN ('USER', 'PERSONAL')
AND COALESCE(dm.audience::jsonb->>'userId', '') ~ '^[0-9]+$'
AND (dm.audience::jsonb->>'userId')::bigint = u.id))))
LEFT JOIN LATERAL (
SELECT d.response_status, d.read_time, d.acknowledge_time, d.process_time,
d.read_duration_ms, d.acknowledge_duration_ms, d.process_duration_ms
FROM t_device_message_detail d
WHERE d.customer_id = dm.customer_id AND d.message_id = dm.id AND d.user_id = u.id
AND COALESCE(d.is_deleted, 0) = 0
ORDER BY d.update_time DESC NULLS LAST, d.create_time DESC, d.id DESC LIMIT 1
) detail ON TRUE
WHERE COALESCE(dm.is_deleted, 0) = 0;
GRANT SELECT ON v_message_effective_recipients TO ljwx_app;
@@ -1,50 +0,0 @@
-- Local direct-SQL companion for V3.1.47.
SELECT set_config('app.current_tenant', '0', false);
WITH target_menu AS (
SELECT id, name
FROM sys_menu
WHERE route_name IN ('health_info', 'health') AND COALESCE(is_deleted, 0) = 0
ORDER BY CASE route_name WHEN 'health_info' THEN 0 ELSE 1 END
LIMIT 1
), desired_permissions(id, name, resource, sort) AS (
VALUES
(310230000010::bigint, '创建健康归档', 't:health:archive:create', 10),
(310230000011::bigint, '恢复健康归档', 't:health:archive:restore', 11),
(310230000012::bigint, '导出健康数据', 't:health:archive:export', 12)
)
INSERT INTO sys_permission (
id, menu_id, menu_name, name, resource, description, sort, status,
customer_id, create_user, create_user_id, create_time,
update_user, update_user_id, update_time, is_deleted
)
SELECT p.id, m.id, m.name, p.name, p.resource, '', p.sort, '1', 0,
'system', 0, NOW(), 'system', 0, NOW(), 0
FROM desired_permissions p CROSS JOIN target_menu m
ON CONFLICT (id) DO UPDATE
SET menu_id = EXCLUDED.menu_id, menu_name = EXCLUDED.menu_name,
name = EXCLUDED.name, resource = EXCLUDED.resource, sort = EXCLUDED.sort,
status = '1', customer_id = 0, update_time = NOW(), is_deleted = 0;
WITH desired_permissions(id) AS (
VALUES (310230000010::bigint), (310230000011::bigint), (310230000012::bigint)
), target_roles AS (
SELECT id AS role_id
FROM sys_role
WHERE COALESCE(is_deleted, 0) = 0
AND role_code IN ('PLATFORM_ADMIN', 'TENANT_ADMIN', 'ADMIN', 'tenant_admin', 'DAdmin')
), missing_grants AS (
SELECT r.role_id, p.id AS permission_id,
310230300000 + ROW_NUMBER() OVER (ORDER BY r.role_id, p.id) AS id
FROM target_roles r CROSS JOIN desired_permissions p
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_permission rp
WHERE rp.role_id = r.role_id AND rp.permission_id = p.id
AND COALESCE(rp.is_deleted, 0) = 0
)
)
INSERT INTO sys_role_permission (
id, role_id, permission_id, create_user, create_user_id, create_time, is_deleted
)
SELECT id, role_id, permission_id, 'system', 0, NOW(), 0 FROM missing_grants
ON CONFLICT (id) DO NOTHING;
@@ -1,10 +0,0 @@
# Epic 360 schema bundle
This directory is the GitOps-owned direct-SQL rollout for
`lingjingwanxiang/ljwx-health#360` at application merge `c7af2fd9`.
The K3s application intentionally runs with `SPRING_FLYWAY_ENABLED=false`.
`job-init-db.yaml` applies these scripts in lexical order with
`ON_ERROR_STOP=1`; `job-flyway-repair.yaml` then verifies the required tables,
view, security functions, and tenant root closure invariant. All scripts are
idempotent and may run on every ArgoCD sync.
@@ -0,0 +1,61 @@
CREATE DATABASE IF NOT EXISTS ljwx_health;
USE ljwx_health;
CREATE TABLE IF NOT EXISTS t_health_raw (
event_id String,
customer_id UInt64,
org_id UInt64,
user_id UInt64,
device_sn String,
ts DateTime64(3, 'Asia/Shanghai'),
heart_rate Nullable(Int16),
blood_oxygen Nullable(Int16),
temperature Nullable(Float64),
pressure_high Nullable(Int16),
pressure_low Nullable(Int16),
stress Nullable(Int16),
step Nullable(Int32),
distance Nullable(Float64),
calorie Nullable(Float64),
version UInt64 DEFAULT 1,
vendor LowCardinality(String) DEFAULT '',
source_adapter LowCardinality(String) DEFAULT '',
precision_tier LowCardinality(String) DEFAULT '',
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
)
ENGINE = ReplacingMergeTree(version)
PARTITION BY toYYYYMM(ts)
ORDER BY (customer_id, event_id)
TTL toDateTime(ts) + INTERVAL 365 DAY DELETE;
ALTER TABLE t_health_raw ADD COLUMN IF NOT EXISTS customer_id UInt64 AFTER event_id;
ALTER TABLE t_health_raw ADD COLUMN IF NOT EXISTS ingested_at DateTime64(3, 'UTC') DEFAULT now64(3);
ALTER TABLE t_health_raw ADD COLUMN IF NOT EXISTS vendor LowCardinality(String) DEFAULT '';
ALTER TABLE t_health_raw ADD COLUMN IF NOT EXISTS source_adapter LowCardinality(String) DEFAULT '';
ALTER TABLE t_health_raw ADD COLUMN IF NOT EXISTS precision_tier LowCardinality(String) DEFAULT '';
ALTER TABLE t_health_raw MODIFY COLUMN ts DateTime64(3, 'Asia/Shanghai');
-- ClickHouse 24.3 does not support CREATE OR REPLACE VIEW. Rebuild this
-- derived, stateless view idempotently while preserving the raw fact table.
DROP VIEW IF EXISTS t_health_raw_business;
CREATE VIEW t_health_raw_business AS
SELECT
customer_id,
event_id,
argMax(org_id, version) AS org_id,
argMax(user_id, version) AS user_id,
argMax(device_sn, version) AS device_sn,
argMax(ts, version) AS ts,
argMax(heart_rate, version) AS heart_rate,
argMax(blood_oxygen, version) AS blood_oxygen,
argMax(temperature, version) AS temperature,
argMax(pressure_high, version) AS pressure_high,
argMax(pressure_low, version) AS pressure_low,
argMax(stress, version) AS stress,
argMax(step, version) AS step,
argMax(distance, version) AS distance,
argMax(calorie, version) AS calorie,
max(version) AS latest_version,
max(ingested_at) AS ingested_at
FROM t_health_raw
GROUP BY customer_id, event_id;
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Copy protected ClickHouse credentials without decoding or printing them.
set -euo pipefail
set +x
SOURCE_NAMESPACE="${SOURCE_NAMESPACE:-infra}"
SOURCE_SECRET="${SOURCE_SECRET:-clickhouse-secret}"
TARGET_NAMESPACE="${TARGET_NAMESPACE:-ljwx-health}"
TARGET_SECRET="${TARGET_SECRET:-ljwx-health-clickhouse-secret}"
KUBECTL="${KUBECTL:-kubectl}"
command -v "$KUBECTL" >/dev/null 2>&1 || {
echo "kubectl executable not found: $KUBECTL" >&2
exit 1
}
command -v jq >/dev/null 2>&1 || {
echo "jq is required" >&2
exit 1
}
source_json="$($KUBECTL -n "$SOURCE_NAMESPACE" get secret "$SOURCE_SECRET" -o json)"
source_user="$(printf '%s' "$source_json" | jq -r '.data.username // empty')"
source_password="$(printf '%s' "$source_json" | jq -r '.data.password // empty')"
if [ -z "$source_user" ] || [ -z "$source_password" ]; then
echo "ClickHouse source Secret is missing username/password" >&2
exit 1
fi
target_patch="$(jq -cn \
--arg user "$source_user" \
--arg password "$source_password" \
'{data:{CLICKHOUSE_USER:$user,CLICKHOUSE_PASSWORD:$password}}')"
if $KUBECTL -n "$TARGET_NAMESPACE" get secret "$TARGET_SECRET" >/dev/null 2>&1; then
printf '%s' "$target_patch" | $KUBECTL -n "$TARGET_NAMESPACE" patch secret "$TARGET_SECRET" \
--type=merge --patch-file=/dev/stdin >/dev/null
else
printf '%s' "$target_patch" | jq \
--arg name "$TARGET_SECRET" \
--arg namespace "$TARGET_NAMESPACE" \
'{apiVersion:"v1",kind:"Secret",metadata:{name:$name,namespace:$namespace},type:"Opaque",data:.data}' \
| $KUBECTL create -f - >/dev/null
fi
$KUBECTL -n "$TARGET_NAMESPACE" annotate secret "$TARGET_SECRET" \
kubectl.kubernetes.io/last-applied-configuration- >/dev/null 2>&1 || true
target_json="$($KUBECTL -n "$TARGET_NAMESPACE" get secret "$TARGET_SECRET" -o json)"
target_user="$(printf '%s' "$target_json" | jq -r '.data.CLICKHOUSE_USER // empty')"
target_password="$(printf '%s' "$target_json" | jq -r '.data.CLICKHOUSE_PASSWORD // empty')"
if [ "$target_user" != "$source_user" ] || [ "$target_password" != "$source_password" ]; then
echo "ClickHouse credential verification failed" >&2
exit 1
fi
unset source_json source_user source_password target_patch target_json target_user target_password
echo "ClickHouse credentials synchronized and verified without disclosure"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Align ljwx-health runtime OSS credentials with the in-cluster MinIO source Secret.
set -euo pipefail
set +x
SOURCE_NAMESPACE="${SOURCE_NAMESPACE:-infra}"
SOURCE_SECRET="${SOURCE_SECRET:-minio-credentials}"
SOURCE_ACCESS_KEY="${SOURCE_ACCESS_KEY:-root-user}"
SOURCE_SECRET_KEY="${SOURCE_SECRET_KEY:-root-password}"
TARGET_NAMESPACE="${TARGET_NAMESPACE:-ljwx-health}"
TARGET_SECRET="${TARGET_SECRET:-ljwx-health-secret}"
CLEAN_SOURCE_LAST_APPLIED="${CLEAN_SOURCE_LAST_APPLIED:-true}"
KUBECTL="${KUBECTL:-kubectl}"
command -v "$KUBECTL" >/dev/null 2>&1 || {
echo "kubectl executable not found: $KUBECTL" >&2
exit 1
}
command -v jq >/dev/null 2>&1 || {
echo "jq is required" >&2
exit 1
}
source_json="$($KUBECTL -n "$SOURCE_NAMESPACE" get secret "$SOURCE_SECRET" -o json)"
source_access="$(printf '%s' "$source_json" | jq -r --arg key "$SOURCE_ACCESS_KEY" '.data[$key] // empty')"
source_secret="$(printf '%s' "$source_json" | jq -r --arg key "$SOURCE_SECRET_KEY" '.data[$key] // empty')"
if [ -z "$source_access" ] || [ -z "$source_secret" ]; then
echo "MinIO source Secret is missing the configured credential keys" >&2
exit 1
fi
$KUBECTL -n "$TARGET_NAMESPACE" get secret "$TARGET_SECRET" >/dev/null
jq -cn \
--arg access "$source_access" \
--arg secret "$source_secret" \
'{data:{OSS_ACCESS_KEY:$access,OSS_SECRET_KEY:$secret}}' \
| $KUBECTL -n "$TARGET_NAMESPACE" patch secret "$TARGET_SECRET" \
--type=merge --patch-file=/dev/stdin >/dev/null
# `kubectl apply` stores base64 Secret values in this annotation. Runtime Secrets
# are intentionally managed by merge patch, so the annotation is both unnecessary
# and an avoidable disclosure surface.
$KUBECTL -n "$TARGET_NAMESPACE" annotate secret "$TARGET_SECRET" \
kubectl.kubernetes.io/last-applied-configuration- >/dev/null 2>&1 || true
if [ "$CLEAN_SOURCE_LAST_APPLIED" = "true" ]; then
$KUBECTL -n "$SOURCE_NAMESPACE" annotate secret "$SOURCE_SECRET" \
kubectl.kubernetes.io/last-applied-configuration- >/dev/null 2>&1 || true
fi
target_json="$($KUBECTL -n "$TARGET_NAMESPACE" get secret "$TARGET_SECRET" -o json)"
target_access="$(printf '%s' "$target_json" | jq -r '.data.OSS_ACCESS_KEY // empty')"
target_secret="$(printf '%s' "$target_json" | jq -r '.data.OSS_SECRET_KEY // empty')"
if [ "$target_access" != "$source_access" ] || [ "$target_secret" != "$source_secret" ]; then
echo "MinIO credential verification failed" >&2
exit 1
fi
unset source_json source_access source_secret target_json target_access target_secret
echo "MinIO credentials synchronized and verified without disclosure"
@@ -0,0 +1,16 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ljwx-admin
namespace: ljwx-health
spec:
template:
metadata:
annotations:
ops.ljwx.io/migration-stage: contabo-canary
spec:
containers:
- name: ljwx-admin
env:
- name: VITE_API_URL
value: https://health-admin-contabo-canary.lingjingwanxiang.cn/api
@@ -0,0 +1,16 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ljwx-bigscreen
namespace: ljwx-health
spec:
template:
metadata:
annotations:
ops.ljwx.io/migration-stage: contabo-canary
spec:
containers:
- name: ljwx-bigscreen
env:
- name: BIGSCREEN_CUTOVER_ADMIN_BASE_URL
value: https://health-dashboard-contabo-canary.lingjingwanxiang.cn
@@ -0,0 +1,22 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ljwx-boot
namespace: ljwx-health
spec:
template:
metadata:
annotations:
ops.ljwx.io/migration-stage: contabo-canary
spec:
containers:
- name: ljwx-boot
env:
- name: LJWX_HEALTH_OUTBOX_KAFKA_ENABLED
value: "false"
- name: LJWX_HEALTH_OUTBOX_KAFKA_ALLOWED_CUSTOMER_IDS
value: ""
- name: LJWX_HEALTH_REALTIME_SCORE_ENABLED
value: "false"
- name: LJWX_HEALTH_REALTIME_SCORE_CUSTOMERS
value: ""
@@ -0,0 +1,69 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ljwx-health-traefik
namespace: ljwx-health
spec:
$patch: replace
ingressClassName: traefik
tls:
- hosts:
- health-admin-contabo-canary.lingjingwanxiang.cn
- health-dashboard-contabo-canary.lingjingwanxiang.cn
secretName: ljwx-health-contabo-canary-tls
rules:
- host: health-admin-contabo-canary.lingjingwanxiang.cn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ljwx-admin
port:
number: 80
- host: health-dashboard-contabo-canary.lingjingwanxiang.cn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ljwx-admin
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ljwx-health-api-traefik
namespace: ljwx-health
spec:
$patch: replace
ingressClassName: traefik
tls:
- hosts:
- health-admin-contabo-canary.lingjingwanxiang.cn
- health-dashboard-contabo-canary.lingjingwanxiang.cn
secretName: ljwx-health-contabo-canary-tls
rules:
- host: health-admin-contabo-canary.lingjingwanxiang.cn
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: ljwx-boot
port:
number: 9998
- host: health-dashboard-contabo-canary.lingjingwanxiang.cn
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: ljwx-boot
port:
number: 9998
@@ -0,0 +1,213 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ljwx-health
resources:
- ../contabo-prod
patches:
- target:
version: v1
kind: ConfigMap
namespace: ljwx-health
name: ljwx-health-cloudflared-config
patch: |-
apiVersion: v1
kind: ConfigMap
metadata:
name: ljwx-health-cloudflared-config
namespace: ljwx-health
$patch: delete
- target:
group: apps
version: v1
kind: Deployment
namespace: ljwx-health
name: ljwx-health-cloudflared
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: ljwx-health-cloudflared
namespace: ljwx-health
$patch: delete
- target:
version: v1
kind: Service
namespace: ljwx-health
name: ljwx-health-cloudflared
patch: |-
apiVersion: v1
kind: Service
metadata:
name: ljwx-health-cloudflared
namespace: ljwx-health
$patch: delete
- target:
group: policy
version: v1
kind: PodDisruptionBudget
namespace: ljwx-health
name: ljwx-health-cloudflared
patch: |-
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ljwx-health-cloudflared
namespace: ljwx-health
$patch: delete
- target:
group: monitoring.coreos.com
version: v1
kind: ServiceMonitor
namespace: ljwx-health
name: ljwx-health-cloudflared
patch: |-
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: ljwx-health-cloudflared
namespace: ljwx-health
$patch: delete
- target:
group: networking.k8s.io
version: v1
kind: NetworkPolicy
namespace: ljwx-health
name: ljwx-health-cloudflared
patch: |-
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ljwx-health-cloudflared
namespace: ljwx-health
$patch: delete
- target:
group: batch
version: v1
kind: Job
namespace: ljwx-health
name: health-shadow-kafka-init
patch: |-
apiVersion: batch/v1
kind: Job
metadata:
name: health-shadow-kafka-init
namespace: ljwx-health
$patch: delete
- target:
group: batch
version: v1
kind: Job
namespace: ljwx-health
name: health-shadow-clickhouse-init
patch: |-
apiVersion: batch/v1
kind: Job
metadata:
name: health-shadow-clickhouse-init
namespace: ljwx-health
$patch: delete
- target:
group: apps
version: v1
kind: Deployment
namespace: ljwx-health
name: health-writer
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: health-writer
namespace: ljwx-health
$patch: delete
- target:
group: apps
version: v1
kind: Deployment
namespace: ljwx-health
name: health-recon
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: health-recon
namespace: ljwx-health
$patch: delete
- target:
version: v1
kind: Service
namespace: ljwx-health
name: health-writer
patch: |-
apiVersion: v1
kind: Service
metadata:
name: health-writer
namespace: ljwx-health
$patch: delete
- target:
version: v1
kind: Service
namespace: ljwx-health
name: health-recon
patch: |-
apiVersion: v1
kind: Service
metadata:
name: health-recon
namespace: ljwx-health
$patch: delete
- target:
group: monitoring.coreos.com
version: v1
kind: ServiceMonitor
namespace: ljwx-health
name: ljwx-health-shadow
patch: |-
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: ljwx-health-shadow
namespace: ljwx-health
$patch: delete
- target:
group: monitoring.coreos.com
version: v1
kind: PrometheusRule
namespace: ljwx-health
name: ljwx-health-shadow
patch: |-
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: ljwx-health-shadow
namespace: ljwx-health
$patch: delete
- target:
group: monitoring.coreos.com
version: v1
kind: PrometheusRule
namespace: ljwx-health
name: ljwx-health-slo
patch: |-
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: ljwx-health-slo
namespace: ljwx-health
$patch: delete
- target:
version: v1
kind: ConfigMap
namespace: ljwx-health
name: ljwx-health-shadow-clickhouse-schema
patch: |-
apiVersion: v1
kind: ConfigMap
metadata:
name: ljwx-health-shadow-clickhouse-schema
namespace: ljwx-health
$patch: delete
- path: ingress-canary-patch.yaml
- path: deployment-boot-staging.yaml
- path: deployment-admin-staging.yaml
- path: deployment-bigscreen-staging.yaml
@@ -0,0 +1,22 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ljwx-health-cloudflared-config
data:
config.yaml: |
tunnel: 70479f3e-caaf-4528-b713-413cc27e8ad0
credentials-file: /etc/cloudflared/credentials/credentials.json
metrics: 0.0.0.0:2000
loglevel: info
ingress:
- hostname: health-admin.omniverseai.net
service: http://traefik.kube-system.svc.cluster.local:80
originRequest:
httpHostHeader: health-admin.omniverseai.net
connectTimeout: 10s
- hostname: health-dashboard.omniverseai.net
service: http://traefik.kube-system.svc.cluster.local:80
originRequest:
httpHostHeader: health-dashboard.omniverseai.net
connectTimeout: 10s
- service: http_status:404
@@ -0,0 +1,37 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ljwx-health-cloudflared
spec:
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- protocol: TCP
port: 80
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 7844
- protocol: UDP
port: 7844
@@ -0,0 +1,55 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ljwx-health-api-traefik
annotations:
cert-manager.io/cluster-issuer: dnspod-letsencrypt
traefik.ingress.kubernetes.io/router.middlewares: ljwx-health-health-api-chain@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- health-admin.lingjingwanxiang.cn
- health-dashboard.lingjingwanxiang.cn
secretName: ljwx-health-contabo-prod-tls
rules:
- host: health-admin.lingjingwanxiang.cn
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: ljwx-boot
port:
number: 9998
- host: health-dashboard.lingjingwanxiang.cn
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: ljwx-boot
port:
number: 9998
- host: health-admin.omniverseai.net
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: ljwx-boot
port:
number: 9998
- host: health-dashboard.omniverseai.net
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: ljwx-boot
port:
number: 9998
@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ljwx-health-ingress
namespace: ljwx-health
spec:
ingress:
- from:
- podSelector: {}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: infra
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/component: controller
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: observability
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
@@ -0,0 +1,55 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ljwx-health-traefik
annotations:
cert-manager.io/cluster-issuer: dnspod-letsencrypt
traefik.ingress.kubernetes.io/router.middlewares: ljwx-health-health-cors@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- health-admin.lingjingwanxiang.cn
- health-dashboard.lingjingwanxiang.cn
secretName: ljwx-health-contabo-prod-tls
rules:
- host: health-admin.lingjingwanxiang.cn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ljwx-admin
port:
number: 80
- host: health-dashboard.lingjingwanxiang.cn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ljwx-admin
port:
number: 80
- host: health-admin.omniverseai.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ljwx-admin
port:
number: 80
- host: health-dashboard.omniverseai.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ljwx-admin
port:
number: 80
@@ -0,0 +1,38 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ljwx-health
resources:
- ../k3s-eu
- middleware-traefik.yaml
- ingress-traefik.yaml
- ingress-api-traefik.yaml
patches:
- target:
group: networking.k8s.io
version: v1
kind: Ingress
namespace: ljwx-health
name: ljwx-health-ingress
patch: |-
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ljwx-health-ingress
namespace: ljwx-health
$patch: delete
- target:
group: networking.k8s.io
version: v1
kind: Ingress
namespace: ljwx-health
name: ljwx-health-api-ingress
patch: |-
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ljwx-health-api-ingress
namespace: ljwx-health
$patch: delete
- path: cloudflared-config-patch.yaml
- path: cloudflared-networkpolicy-patch.yaml
- path: ingress-networkpolicy-patch.yaml
@@ -0,0 +1,52 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: health-cors
spec:
headers:
accessControlAllowCredentials: true
accessControlAllowHeaders:
- DNT
- X-CustomHeader
- Keep-Alive
- User-Agent
- X-Requested-With
- If-Modified-Since
- Cache-Control
- Content-Type
- Authorization
accessControlAllowMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
accessControlAllowOriginList:
- https://health-admin.lingjingwanxiang.cn
- https://health-dashboard.lingjingwanxiang.cn
- https://health-admin.omniverseai.net
- https://health-dashboard.omniverseai.net
- https://health-admin-contabo-canary.lingjingwanxiang.cn
- https://health-dashboard-contabo-canary.lingjingwanxiang.cn
accessControlMaxAge: 600
addVaryHeader: true
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: health-strip-api
spec:
stripPrefix:
prefixes:
- /api
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: health-api-chain
spec:
chain:
middlewares:
- name: health-strip-api
- name: health-cors
@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ljwx-boot
namespace: ljwx-health
spec:
template:
metadata:
annotations:
ops.ljwx.io/config-revision: health-shadow-realtime-score-tenant-1-v2
spec:
containers:
- name: ljwx-boot
env:
- name: LJWX_HEALTH_OUTBOX_KAFKA_ENABLED
value: "true"
- name: LJWX_HEALTH_OUTBOX_KAFKA_ALLOWED_CUSTOMER_IDS
value: "1"
- name: LJWX_HEALTH_OUTBOX_KAFKA_ALLOW_ALL_TENANTS
value: "false"
- name: LJWX_HEALTH_OUTBOX_KAFKA_BOOTSTRAP_SERVERS
value: kafka-cluster-kafka-bootstrap.shared-base.svc.cluster.local:9092
- name: LJWX_HEALTH_REALTIME_SCORE_ENABLED
value: "true"
- name: LJWX_HEALTH_REALTIME_SCORE_CUSTOMERS
value: "1"
- name: LJWX_HEALTH_REALTIME_SCORE_ROLLOUT_PERCENTAGE
value: "0"
- name: LJWX_HEALTH_REALTIME_SCORE_FLUSH_INTERVAL_MS
value: "60000"
@@ -3,15 +3,28 @@ kind: Kustomization
namespace: ljwx-health
resources:
- ../../base
- ../../schema-bundle
- schema-release.yaml
patches:
- path: deployment-bigscreen-cutover.yaml
- path: deployment-boot-shadow-canary.yaml
- path: schema-job-release-patch.yaml
images:
- name: harbor.omniverseai.net/ljwx-health/ljwx-boot
newName: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-boot
digest: sha256:d0e0880dae069adca88f81427e43a35621b05ef410df6f6c0f66b3809c626d36
digest: sha256:c9bcea82ef9da03b3315a5550cb4cbfa456f71d6267e492f304aa085cb7f50ba
- name: harbor.omniverseai.net/ljwx-health/ljwx-admin
newName: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-admin
digest: sha256:1bf4fe7f9778143647928b5b2a7e20fafef7237e4c296cff23ebc739917d008d
digest: sha256:f212cab3e2c4cfc9161443d90bfc637137686d62b8417074365b3f8dc623c8e3
- name: harbor.omniverseai.net/ljwx-health/ljwx-bigscreen
newName: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-bigscreen
digest: sha256:83a35ae4674c16a9f54d0d6ea535fe941001b58e450db32ba76c0d3fdd4b6e1f
digest: sha256:a3a5e2141d74380b0b85408b855692f0d61e56d108e18023e9551fed5aaf3657
- name: harbor.omniverseai.net/ljwx-health/health-writer
newName: ghcr.io/lingjingwanxiang/ljwx-health/health-writer
digest: sha256:8bc97dd8af74fb768cf47b818c000f9d4b554f83871a0fb4bbf271c548ead6dd
- name: harbor.omniverseai.net/ljwx-health/health-recon
newName: ghcr.io/lingjingwanxiang/ljwx-health/health-recon
digest: sha256:35cd383ff02f258fe6f81e924371fffc0be19b49bd711c5ecf703ce6e76071f2
- name: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner
newName: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner
digest: sha256:0d077562a841fa881fa5f4454cea64defa3f345582f96b00c94b7adffdd6c11c
@@ -0,0 +1,62 @@
apiVersion: delivery.lingjingwanxiang.cn/v1alpha1
kind: LJWXHealthPromotion
receiptDigest: sha256:06ee4823163efd0681d042a94eea9958f0b50ea17f0d6517b25b20d65620e2b3
source:
repository: lingjingwanxiang/ljwx-health
sha: ee2e655bd137cf83e5f43c638009ed1a3a0fc839
tag: ljwx-health-release-ee2e655bd137cf83e5f43c638009ed1a3a0fc839-run-30660382221-attempt-1
manifestDigest: sha256:4cb7ffe007a4f8944c910b415e667aa474e792a56e1eb3438bb7db94cf2abe68
releaseManifest:
contentDigest: sha256:4cb7ffe007a4f8944c910b415e667aa474e792a56e1eb3438bb7db94cf2abe68
ociRef: ghcr.io/lingjingwanxiang/ljwx-health/release-manifest@sha256:6b47536d82a9b4a821aa34968ad6f98dd709f50baeb7649465923d563d1ecf55
ociDigest: sha256:6b47536d82a9b4a821aa34968ad6f98dd709f50baeb7649465923d563d1ecf55
schema:
version: 3.1.72
manifestDigest: sha256:e4d7195bd821fe6765234499fd313064ee2c591f7169d21a380c234b7c3f76d0
bundleDigest: sha256:783efd481f9af5d6f9459f53ed20a5d8758ab96cfd25ce25b63814b6282cbfcd
bundleMediaType: application/vnd.ljwx.health.schema.bundle.v2+tar+gzip
expectedProductionFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
canonicalProductionFingerprint: sha256:3440ae5ba527de93c5f1dae245c87552250c891c36476ff43dfa267173e95c12
adoption:
catalogComparison:
canonicalFingerprint: sha256:3440ae5ba527de93c5f1dae245c87552250c891c36476ff43dfa267173e95c12
evidenceDigest: sha256:290e7fac704fc4b706ed1449b1e979d0a0395383e91817328a9f4b54dad4f079
evidencePath: ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.allowed-catalog-differences.json
environment: ljwx-health-k3s-eu
expectedPostFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
expectedPreFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
planDigest: sha256:f39b85d57258492ff0af6693268a9239af9cc6449ced1104673a006fa6c6e29e
planPath: ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.json
profile: production-pg18
ociRef: ghcr.io/lingjingwanxiang/ljwx-health/schema-bundle@sha256:26f4ef828dde53804b50cc1bf6d5f01491ae6d149ef0606e53358d99bc4ef06b
ociDigest: sha256:26f4ef828dde53804b50cc1bf6d5f01491ae6d149ef0606e53358d99bc4ef06b
runnerImageRef: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner@sha256:0d077562a841fa881fa5f4454cea64defa3f345582f96b00c94b7adffdd6c11c
signingIdentity: https://github.com/lingjingwanxiang/ljwx-workflow-templates/.github/workflows/release-ljwx-health.yml@fd260a24f92edef33f8b99c1047d5c8b80586223
signingIssuer: https://token.actions.githubusercontent.com
binding:
predicateDigest: sha256:73fe489662e0c8c741ba6703d06d957c3908312805df1f596a1136792e68723a
attestationDigest: sha256:1e4e5bfb9d168db75f5aa710dd9b999a9093dd0b0f102cbcbd190f4f7dd320f5
build:
runId: '30660382221'
runAttempt: '1'
components:
- name: ljwx-boot
image: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-boot
digest: sha256:c9bcea82ef9da03b3315a5550cb4cbfa456f71d6267e492f304aa085cb7f50ba
imageRef: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-boot@sha256:c9bcea82ef9da03b3315a5550cb4cbfa456f71d6267e492f304aa085cb7f50ba
- name: health-writer
image: ghcr.io/lingjingwanxiang/ljwx-health/health-writer
digest: sha256:8bc97dd8af74fb768cf47b818c000f9d4b554f83871a0fb4bbf271c548ead6dd
imageRef: ghcr.io/lingjingwanxiang/ljwx-health/health-writer@sha256:8bc97dd8af74fb768cf47b818c000f9d4b554f83871a0fb4bbf271c548ead6dd
- name: health-recon
image: ghcr.io/lingjingwanxiang/ljwx-health/health-recon
digest: sha256:35cd383ff02f258fe6f81e924371fffc0be19b49bd711c5ecf703ce6e76071f2
imageRef: ghcr.io/lingjingwanxiang/ljwx-health/health-recon@sha256:35cd383ff02f258fe6f81e924371fffc0be19b49bd711c5ecf703ce6e76071f2
- name: ljwx-admin
image: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-admin
digest: sha256:f212cab3e2c4cfc9161443d90bfc637137686d62b8417074365b3f8dc623c8e3
imageRef: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-admin@sha256:f212cab3e2c4cfc9161443d90bfc637137686d62b8417074365b3f8dc623c8e3
- name: ljwx-bigscreen
image: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-bigscreen
digest: sha256:a3a5e2141d74380b0b85408b855692f0d61e56d108e18023e9551fed5aaf3657
imageRef: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-bigscreen@sha256:a3a5e2141d74380b0b85408b855692f0d61e56d108e18023e9551fed5aaf3657
@@ -0,0 +1,15 @@
apiVersion: batch/v1
kind: Job
metadata:
name: ljwx-health-schema-bundle
namespace: ljwx-health
spec:
template:
spec:
containers:
- name: schema-bundle
envFrom:
- configMapRef:
name: ljwx-health-config
- configMapRef:
name: ljwx-health-schema-release-783efd481f9af5d6
@@ -0,0 +1,34 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ljwx-health-schema-release-783efd481f9af5d6
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: '-3'
labels:
app.kubernetes.io/name: ljwx-health
app.kubernetes.io/component: schema-release
immutable: true
data:
sourceSha: ee2e655bd137cf83e5f43c638009ed1a3a0fc839
releaseManifestDigest: sha256:4cb7ffe007a4f8944c910b415e667aa474e792a56e1eb3438bb7db94cf2abe68
releaseManifestOciRef: ghcr.io/lingjingwanxiang/ljwx-health/release-manifest@sha256:6b47536d82a9b4a821aa34968ad6f98dd709f50baeb7649465923d563d1ecf55
schemaVersion: 3.1.72
schemaManifestDigest: sha256:e4d7195bd821fe6765234499fd313064ee2c591f7169d21a380c234b7c3f76d0
schemaBundleDigest: sha256:783efd481f9af5d6f9459f53ed20a5d8758ab96cfd25ce25b63814b6282cbfcd
schemaBundleMediaType: application/vnd.ljwx.health.schema.bundle.v2+tar+gzip
schemaOciRef: ghcr.io/lingjingwanxiang/ljwx-health/schema-bundle@sha256:26f4ef828dde53804b50cc1bf6d5f01491ae6d149ef0606e53358d99bc4ef06b
schemaRunnerImageRef: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner@sha256:0d077562a841fa881fa5f4454cea64defa3f345582f96b00c94b7adffdd6c11c
expectedProductionFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
canonicalProductionFingerprint: sha256:3440ae5ba527de93c5f1dae245c87552250c891c36476ff43dfa267173e95c12
adoptionPlanPath: ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.json
adoptionPlanDigest: sha256:f39b85d57258492ff0af6693268a9239af9cc6449ced1104673a006fa6c6e29e
adoptionEvidencePath: ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.allowed-catalog-differences.json
adoptionEvidenceDigest: sha256:290e7fac704fc4b706ed1449b1e979d0a0395383e91817328a9f4b54dad4f079
adoptionExpectedPreFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
adoptionExpectedPostFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
signingIdentity: https://github.com/lingjingwanxiang/ljwx-workflow-templates/.github/workflows/release-ljwx-health.yml@fd260a24f92edef33f8b99c1047d5c8b80586223
signingIssuer: https://token.actions.githubusercontent.com
bindingPredicateDigest: sha256:73fe489662e0c8c741ba6703d06d957c3908312805df1f596a1136792e68723a
bindingAttestationDigest: sha256:1e4e5bfb9d168db75f5aa710dd9b999a9093dd0b0f102cbcbd190f4f7dd320f5
@@ -0,0 +1,374 @@
apiVersion: batch/v1
kind: Job
metadata:
name: ljwx-health-schema-bundle
namespace: ljwx-health
labels:
app: ljwx-health
component: schema-bundle
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "-2"
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app: ljwx-health
component: schema-bundle
spec:
restartPolicy: Never
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-pull
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: schema-bundle
image: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner:unpromoted
imagePullPolicy: IfNotPresent
command:
- /bin/bash
- -ceu
- |
export HOME=/work/home
export DOCKER_CONFIG=/registry
mkdir -p "${HOME}" /work/release /work/schema /work/bundle
for name in \
sourceSha releaseManifestDigest releaseManifestOciRef \
schemaVersion schemaManifestDigest schemaBundleDigest \
schemaBundleMediaType schemaOciRef schemaRunnerImageRef \
expectedProductionFingerprint canonicalProductionFingerprint \
adoptionPlanPath adoptionPlanDigest adoptionEvidencePath \
adoptionEvidenceDigest adoptionExpectedPreFingerprint \
adoptionExpectedPostFingerprint signingIdentity signingIssuer \
bindingPredicateDigest bindingAttestationDigest; do
value="$(printenv "${name}")"
test -n "${value}"
done
case "${schemaRunnerImageRef}" in
ghcr.io/lingjingwanxiang/ljwx-health/schema-runner@sha256:*) ;;
*) exit 1 ;;
esac
cosign verify \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${releaseManifestOciRef}" \
>/work/release-signature-verification.json
oras pull "${releaseManifestOciRef}" --output /work/release
python3 - "${releaseManifestDigest}" \
/work/release/release-manifest-core.json <<'PY'
import hashlib
import json
import sys
from pathlib import Path
expected = sys.argv[1]
payload = json.loads(Path(sys.argv[2]).read_text())
actual = payload.pop("manifestDigest", None)
canonical = json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode()
computed = "sha256:" + hashlib.sha256(canonical).hexdigest()
if actual != expected or computed != expected:
raise SystemExit(
"signed Release core content digest is invalid"
)
PY
cosign verify \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/work/schema-signature-verification.json
cosign verify-attestation \
--type custom \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/work/schema-binding-verification.json
cosign verify-attestation \
--type spdxjson \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/work/schema-sbom-verification.json
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/work/schema-provenance-verification.json
cat >/work/schema-release-binding.predicate.json <<JSON
{
"schemaVersion": 1,
"kind": "LJWXHealthSchemaReleaseBinding",
"schemaSubject": "${schemaOciRef}",
"releaseManifest": {
"contentDigest": "${releaseManifestDigest}",
"ociRef": "${releaseManifestOciRef}",
"ociDigest": "${releaseManifestOciRef##*@}"
},
"source": {
"repository": "lingjingwanxiang/ljwx-health",
"sha": "${sourceSha}"
}
}
JSON
actual_predicate_digest="$(
sha256sum /work/schema-release-binding.predicate.json \
| awk '{print "sha256:" $1}'
)"
test "${actual_predicate_digest}" = "${bindingPredicateDigest}"
python3 - /work/schema-release-binding.predicate.json \
/work/schema-binding-verification.json \
"${schemaOciRef##*@}" <<'PY'
import base64
import json
import sys
from pathlib import Path
expected = json.loads(Path(sys.argv[1]).read_text())
raw = Path(sys.argv[2]).read_text().strip()
digest = sys.argv[3].removeprefix("sha256:")
try:
parsed = json.loads(raw)
documents = parsed if isinstance(parsed, list) else [parsed]
except json.JSONDecodeError:
documents = [
json.loads(line)
for line in raw.splitlines()
if line.strip()
]
for document in documents:
encoded = document.get("payload")
if not isinstance(encoded, str):
continue
statement = json.loads(base64.b64decode(encoded))
subjects = statement.get("subject", [])
predicate = statement.get("predicate")
if (
isinstance(predicate, dict)
and isinstance(predicate.get("Data"), str)
):
predicate = json.loads(predicate["Data"])
if (
any(
item.get("digest", {}).get("sha256") == digest
for item in subjects
if isinstance(item, dict)
)
and predicate == expected
):
break
else:
raise SystemExit(
"verified schema attestation does not bind this Release"
)
PY
oras pull "${schemaOciRef}" --output /work/schema
actual_bundle_digest="$(
sha256sum /work/schema/ljwx-health-schema-bundle.tar.gz \
| awk '{print "sha256:" $1}'
)"
test "${actual_bundle_digest}" = "${schemaBundleDigest}"
tar -xzf /work/schema/ljwx-health-schema-bundle.tar.gz \
-C /work/bundle
python3 - /work/bundle/bundle-metadata.json <<'PY'
import json
import hashlib
import os
import sys
from pathlib import Path
metadata = json.loads(Path(sys.argv[1]).read_text())
sbom_path = Path("/work/bundle/bundle-sbom.spdx.json")
provenance_path = Path(
"/work/bundle/bundle-provenance.intoto.json"
)
digest = lambda path: (
"sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
)
if (
digest(sbom_path) != metadata["sbomDigest"]
or digest(provenance_path)
!= metadata["provenanceDigest"]
):
raise SystemExit(
"schema bundle SBOM/provenance digest mismatch"
)
provenance = json.loads(provenance_path.read_text())
parameters = provenance["predicate"]["buildDefinition"][
"externalParameters"
]
production = next(
item for item in metadata["fingerprints"]
if item["profile"] == "production-pg18"
)
expected = {
"source": metadata["source"]["sha"],
"runner": metadata["runner"]["imageRef"],
"manifest": metadata["schemaManifestDigest"],
"media": metadata["mediaType"],
"canonicalProduction": production["digest"],
"adoptedProduction": metadata["adoption"][
"expectedPostFingerprint"
],
"adoptionPlan": metadata["adoption"]["planDigest"],
"adoptionEvidence": metadata["adoption"][
"catalogComparison"
]["evidenceDigest"],
"provenanceSource": parameters["sourceSha"],
"provenanceRunner": parameters["runnerImageRef"],
}
actual = {
"source": os.environ["sourceSha"],
"runner": os.environ["schemaRunnerImageRef"],
"manifest": os.environ["schemaManifestDigest"],
"media": os.environ["schemaBundleMediaType"],
"canonicalProduction": os.environ[
"canonicalProductionFingerprint"
],
"adoptedProduction": os.environ[
"expectedProductionFingerprint"
],
"adoptionPlan": os.environ["adoptionPlanDigest"],
"adoptionEvidence": os.environ[
"adoptionEvidenceDigest"
],
"provenanceSource": os.environ["sourceSha"],
"provenanceRunner": os.environ["schemaRunnerImageRef"],
}
if actual != expected:
raise SystemExit(
f"schema bundle metadata mismatch: {actual} != {expected}"
)
adoption = metadata["adoption"]
comparison = adoption["catalogComparison"]
plan_path = Path("/work/bundle") / os.environ[
"adoptionPlanPath"
]
evidence_path = Path("/work/bundle") / os.environ[
"adoptionEvidencePath"
]
plan = json.loads(plan_path.read_text())
plan_digest = "sha256:" + hashlib.sha256(
json.dumps(
plan,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
if (
adoption["planPath"] != os.environ["adoptionPlanPath"]
or plan_digest != os.environ["adoptionPlanDigest"]
or comparison["evidencePath"]
!= os.environ["adoptionEvidencePath"]
or digest(evidence_path)
!= os.environ["adoptionEvidenceDigest"]
or adoption["expectedPreFingerprint"]
!= os.environ["adoptionExpectedPreFingerprint"]
or adoption["expectedPostFingerprint"]
!= os.environ["adoptionExpectedPostFingerprint"]
or plan["catalogComparison"] != comparison
):
raise SystemExit(
"schema bundle adoption contract mismatch"
)
PY
cd /work/bundle
export PGHOST="${DB_HOST}"
export PGPORT="${DB_PORT}"
export PGDATABASE="${DB_NAME}"
export LJWX_SCHEMA_SOURCE_SHA="${sourceSha}"
export LJWX_SCHEMA_ALLOW_PRODUCTION=true
DB_MIGRATOR_PASSWORD="$(cat /credentials/DB_PASSWORD)"
export DB_MIGRATOR_PASSWORD
adoption_plan=ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.json
export PGUSER=ljwx_migrator
export PGPASSWORD="${DB_MIGRATOR_PASSWORD}"
verify_schema() {
ljwx-boot/scripts/schema-verify.sh \
--profile production-pg18 \
--target production \
--expected-database "${DB_NAME}" \
--database "${DB_NAME}" \
--output /work/verify-evidence.json
}
if verify_schema; then
echo "Production schema already satisfies the complete signed manifest; skipping retry upgrade"
else
ljwx-boot/scripts/schema-upgrade.sh \
--profile production-pg18 \
--target production \
--expected-database "${DB_NAME}" \
--database "${DB_NAME}" \
--adoption-plan "${adoption_plan}" \
--output /work/upgrade-evidence.json
verify_schema
fi
actual_fingerprint="$(
jq -r .logicalFingerprint /work/verify-evidence.json
)"
if [ "${actual_fingerprint}" != "${expectedProductionFingerprint}" ]; then
echo "Production schema fingerprint mismatch: expected=${expectedProductionFingerprint}, actual=${actual_fingerprint}" >&2
exit 1
fi
envFrom:
- configMapRef:
name: ljwx-health-config
- configMapRef:
name: ljwx-health-schema-release-unpromoted
volumeMounts:
- name: work
mountPath: /work
- name: registry
mountPath: /registry
readOnly: true
- name: migrator-credentials
mountPath: /credentials
readOnly: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
volumes:
- name: work
emptyDir: {}
- name: registry
secret:
secretName: ghcr-pull
items:
- key: .dockerconfigjson
path: config.json
- name: migrator-credentials
secret:
secretName: ljwx-health-schema-migrator
items:
- key: DB_PASSWORD
path: DB_PASSWORD
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ljwx-health
resources:
- job-schema-bundle.yaml
@@ -0,0 +1,36 @@
# LJWX Health isolated schema restore drill
This package is deliberately dormant. No Argo CD application references it
until a reviewed activation commit pins all of the following:
- an exact signed LJWX Health Release receipt and schema ConfigMap;
- the schema-runner digest from that same Release;
- an exact pgBackRest backup label;
- a unique immutable Job name and either `pre-adoption` or `post-adoption`.
The cross-namespace read-only Role and RoleBinding live in
`apps/postgres-infra-operations/phase5-schema-restore-drill-rbac.yaml`. That
resource is synced by the PostgreSQL operations Argo application before the
restore Job is activated. Keeping it outside this
package prevents the parent LJWX Health namespace transformer from moving the
Role out of `infra`.
The Job restores only to a 12 GiB `emptyDir`. It never mounts a production PVC
or a production application Secret. Egress is limited to cluster DNS, the
Kubernetes API, the MinIO backup endpoint, and public HTTPS needed for
GHCR/Sigstore verification; production PostgreSQL port 5432 is not reachable.
The `pre-adoption` mode applies the signed adoption plan, upgrade, and complete
verify to a backup taken before production adoption. The `post-adoption` mode
re-runs the signed upgrade idempotently and verifies the already adopted
history. Both modes then start the exact `ljwx-boot@sha256` from the same
Release against the restored database and an ephemeral Redis process. The
application smoke uses generated credentials only and never reads the
production application Secret. Both modes report the exact backup and source
database identity, pgBackRest repository verification, RPO, end-to-end RTO,
critical row counts, catalog/RLS/extension inventory, TimescaleDB absence or
object counts, normalized fingerprint, and application readiness result.
Activation and cleanup are GitOps changes. Preserve Job logs and the
`RESTORE_DRILL_PASS` JSON before a later cleanup commit removes the Job and its
ephemeral volume.
@@ -0,0 +1,393 @@
apiVersion: batch/v1
kind: Job
metadata:
name: ljwx-health-schema-restore-drill-unconfigured
namespace: ljwx-health
labels:
app.kubernetes.io/name: ljwx-health
app.kubernetes.io/component: schema-restore-drill
annotations:
argocd.argoproj.io/sync-wave: "4"
operations.ljwx.io/activation: dormant-requires-exact-release-and-backup
spec:
backoffLimit: 0
activeDeadlineSeconds: 3600
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app.kubernetes.io/name: ljwx-health
app.kubernetes.io/component: schema-restore-drill
spec:
restartPolicy: Never
serviceAccountName: ljwx-health-schema-restore-drill
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-pull
securityContext:
fsGroup: 1001
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
- name: prepare-volumes
image: docker.io/bitnami/postgresql@sha256:9b62e6057d781853916a712ad2591a607de4529160e07c2eaf58d75ef6c45b9d
command:
- /bin/bash
- -ceu
- |
chmod 0700 /restore
chmod 0770 /socket /evidence
chown 1001:1001 /restore /socket /evidence
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
securityContext:
runAsNonRoot: false
runAsUser: 0
runAsGroup: 0
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["CHOWN"]
volumeMounts:
- name: restore
mountPath: /restore
- name: socket
mountPath: /socket
- name: evidence
mountPath: /evidence
- name: verify-release-and-fetch-backup-config
image: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner:unpromoted
imagePullPolicy: IfNotPresent
command:
- /bin/bash
- /scripts/prepare-release-and-backup.sh
envFrom:
- configMapRef:
name: ljwx-health-schema-release-unpromoted
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
- name: work
mountPath: /work
- name: evidence
mountPath: /evidence
- name: backup-credentials
mountPath: /credentials
- name: pgbackrest-config
mountPath: /pgbackrest
- name: registry
mountPath: /registry
readOnly: true
- name: kube-api-access
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
readOnly: true
containers:
- name: restore-postgres
image: docker.io/bitnami/postgresql@sha256:9b62e6057d781853916a712ad2591a607de4529160e07c2eaf58d75ef6c45b9d
imagePullPolicy: IfNotPresent
command:
- /bin/bash
- /scripts/restore-postgres.sh
env:
- name: BACKUP_LABEL
value: BACKUP_LABEL_REQUIRED
resources:
requests:
cpu: 250m
memory: 512Mi
ephemeral-storage: 4Gi
limits:
cpu: "2"
memory: 2Gi
ephemeral-storage: 12Gi
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
- name: restore
mountPath: /restore
- name: socket
mountPath: /socket
- name: evidence
mountPath: /evidence
- name: backup-credentials
mountPath: /credentials
readOnly: true
- name: pgbackrest-config
mountPath: /etc/pgbackrest
readOnly: true
- name: tmp
mountPath: /tmp
- name: verify-restored-schema
image: ghcr.io/lingjingwanxiang/ljwx-health/schema-runner:unpromoted
imagePullPolicy: IfNotPresent
command:
- /bin/bash
- /scripts/verify-restored-schema.sh
envFrom:
- configMapRef:
name: ljwx-health-schema-release-unpromoted
env:
- name: BACKUP_LABEL
value: BACKUP_LABEL_REQUIRED
- name: RESTORE_MODE
value: pre-adoption
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
- name: work
mountPath: /work
readOnly: true
- name: socket
mountPath: /socket
- name: evidence
mountPath: /evidence
- name: tmp
mountPath: /tmp
- name: ephemeral-redis
image: harbor.omniverseai.net/library/redis-stack-server@sha256:768c7c0782c0ffc931caf934c5491de6fe2a195b1f7be748ddd34746f50b3c22
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- /scripts/run-ephemeral-redis.sh
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 250m
memory: 512Mi
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
- name: evidence
mountPath: /evidence
- name: redis-tmp
mountPath: /tmp
- name: smoke-restored-application
image: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-boot:unpromoted
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- /scripts/smoke-restored-application.sh
env:
- name: LJWX_BOOT_IMAGE_REF
value: ghcr.io/lingjingwanxiang/ljwx-health/ljwx-boot:unpromoted
- name: SPRING_PROFILES_ACTIVE
value: k8s
- name: SERVER_PORT
value: "19998"
- name: DB_HOST
value: 127.0.0.1
- name: DB_PORT
value: "55432"
- name: DB_NAME
value: ljwx_health
- name: DB_USER
value: ljwx_app
- name: DB_PASSWORD
value: restore-drill-trust-only
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://127.0.0.1:55432/ljwx_health?currentSchema=public&stringtype=unspecified
- name: SPRING_DATASOURCE_USERNAME
value: ljwx_app
- name: SPRING_DATASOURCE_PASSWORD
value: restore-drill-trust-only
- name: SPRING_DATASOURCE_DRIVER_CLASS_NAME
value: org.postgresql.Driver
- name: SPRING_FLYWAY_ENABLED
value: "false"
- name: REDIS_HOST
value: 127.0.0.1
- name: REDIS_PORT
value: "6379"
- name: REDIS_DB
value: "15"
- name: REDIS_PASSWORD
value: ""
- name: SPRING_REDIS_HOST
value: 127.0.0.1
- name: SPRING_REDIS_PORT
value: "6379"
- name: SPRING_REDIS_DATABASE
value: "15"
- name: SPRING_REDIS_PASSWORD
value: ""
- name: JAVA_TOOL_OPTIONS
value: ""
- name: JAVA_OPTS
value: -Xms128m -Xmx640m -Duser.timezone=Asia/Shanghai
- name: OTEL_TRACES_EXPORTER
value: none
- name: OTEL_METRICS_EXPORTER
value: none
- name: OTEL_LOGS_EXPORTER
value: none
- name: MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS
value: never
- name: MANAGEMENT_ENDPOINT_HEALTH_SHOW_COMPONENTS
value: never
- name: LOGGING_LEVEL_COM_LJWX_STARTER_SATOKEN_LOG
value: "OFF"
- name: OSS_ACCESS_KEY
value: restore-drill
- name: OSS_SECRET_KEY
value: restore-drill
- name: OSS_BUCKET_NAME
value: restore-drill
- name: OSS_ENDPOINT
value: http://127.0.0.1:1
- name: LJWX_LICENSE_ENABLED
value: "false"
resources:
requests:
cpu: 250m
memory: 768Mi
limits:
cpu: "2"
memory: 1536Mi
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
- name: evidence
mountPath: /evidence
- name: app-logs
mountPath: /app/logs
- name: app-uploads
mountPath: /app/uploads
- name: app-tmp
mountPath: /tmp
volumes:
- name: scripts
configMap:
name: ljwx-health-schema-restore-drill-scripts
defaultMode: 0550
- name: restore
emptyDir:
sizeLimit: 12Gi
- name: socket
emptyDir:
medium: Memory
sizeLimit: 64Mi
- name: evidence
emptyDir:
medium: Memory
sizeLimit: 128Mi
- name: backup-credentials
emptyDir:
medium: Memory
sizeLimit: 1Mi
- name: pgbackrest-config
emptyDir:
medium: Memory
sizeLimit: 1Mi
- name: work
emptyDir:
sizeLimit: 512Mi
- name: registry
secret:
secretName: ghcr-pull
items:
- key: .dockerconfigjson
path: config.json
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 512Mi
- name: redis-tmp
emptyDir:
medium: Memory
sizeLimit: 128Mi
- name: app-logs
emptyDir:
sizeLimit: 256Mi
- name: app-uploads
emptyDir:
sizeLimit: 64Mi
- name: app-tmp
emptyDir:
medium: Memory
sizeLimit: 256Mi
- name: kube-api-access
projected:
defaultMode: 0440
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600
- configMap:
name: kube-root-ca.crt
items:
- key: ca.crt
path: ca.crt
- downwardAPI:
items:
- path: namespace
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
@@ -0,0 +1,21 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rbac.yaml
- networkpolicy.yaml
- job-schema-restore-drill.yaml
configMapGenerator:
- name: ljwx-health-schema-restore-drill-scripts
namespace: ljwx-health
files:
- scripts/prepare-release-and-backup.sh
- scripts/restore-postgres.sh
- scripts/verify-restored-schema.sh
- scripts/run-ephemeral-redis.sh
- scripts/smoke-restored-application.sh
generatorOptions:
labels:
app.kubernetes.io/name: ljwx-health
app.kubernetes.io/component: schema-restore-drill
annotations:
argocd.argoproj.io/sync-wave: "-2"
@@ -0,0 +1,58 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ljwx-health-schema-restore-drill
namespace: ljwx-health
annotations:
argocd.argoproj.io/sync-wave: "-2"
spec:
podSelector:
matchLabels:
app.kubernetes.io/component: schema-restore-drill
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- ipBlock:
cidr: 10.43.0.1/32
ports:
- protocol: TCP
port: 443
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: infra
podSelector:
matchLabels:
app: minio
ports:
- protocol: TCP
port: 9000
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 100.64.0.0/10
- 127.0.0.0/8
- 169.254.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- 224.0.0.0/4
ports:
- protocol: TCP
port: 443
@@ -0,0 +1,8 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: ljwx-health-schema-restore-drill
namespace: ljwx-health
annotations:
argocd.argoproj.io/sync-wave: "-2"
automountServiceAccountToken: false
@@ -0,0 +1,286 @@
#!/usr/bin/env bash
# shellcheck disable=SC2154 # Release fields are injected by envFrom.
set -euo pipefail
export HOME=/work/home
export DOCKER_CONFIG=/registry
mkdir -p "${HOME}" /work/release /work/schema /work/bundle
for name in \
sourceSha releaseManifestDigest releaseManifestOciRef \
schemaVersion schemaManifestDigest schemaBundleDigest \
schemaBundleMediaType schemaOciRef schemaRunnerImageRef \
expectedProductionFingerprint canonicalProductionFingerprint \
adoptionPlanPath adoptionPlanDigest adoptionEvidencePath \
adoptionEvidenceDigest adoptionExpectedPreFingerprint \
adoptionExpectedPostFingerprint signingIdentity signingIssuer \
bindingPredicateDigest bindingAttestationDigest; do
test -n "$(printenv "${name}")"
done
case "${schemaRunnerImageRef}" in
ghcr.io/lingjingwanxiang/ljwx-health/schema-runner@sha256:*) ;;
*) exit 1 ;;
esac
cosign verify \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${releaseManifestOciRef}" \
>/evidence/release-signature-verification.json
oras pull "${releaseManifestOciRef}" --output /work/release
python3 - "${releaseManifestDigest}" \
/work/release/release-manifest-core.json <<'PY'
import hashlib
import json
import sys
from pathlib import Path
expected = sys.argv[1]
payload = json.loads(Path(sys.argv[2]).read_text())
actual = payload.pop("manifestDigest", None)
canonical = json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode()
computed = "sha256:" + hashlib.sha256(canonical).hexdigest()
if actual != expected or computed != expected:
raise SystemExit("signed Release core content digest is invalid")
PY
cosign verify \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/evidence/schema-signature-verification.json
cosign verify-attestation \
--type custom \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/evidence/schema-binding-verification.json
cosign verify-attestation \
--type spdxjson \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/evidence/schema-sbom-verification.json
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity "${signingIdentity}" \
--certificate-oidc-issuer "${signingIssuer}" \
"${schemaOciRef}" \
>/evidence/schema-provenance-verification.json
cat >/work/schema-release-binding.predicate.json <<JSON
{
"schemaVersion": 1,
"kind": "LJWXHealthSchemaReleaseBinding",
"schemaSubject": "${schemaOciRef}",
"releaseManifest": {
"contentDigest": "${releaseManifestDigest}",
"ociRef": "${releaseManifestOciRef}",
"ociDigest": "${releaseManifestOciRef##*@}"
},
"source": {
"repository": "lingjingwanxiang/ljwx-health",
"sha": "${sourceSha}"
}
}
JSON
actual_predicate_digest="$(
sha256sum /work/schema-release-binding.predicate.json \
| awk '{print "sha256:" $1}'
)"
test "${actual_predicate_digest}" = "${bindingPredicateDigest}"
python3 - /work/schema-release-binding.predicate.json \
/evidence/schema-binding-verification.json \
"${schemaOciRef##*@}" <<'PY'
import base64
import json
import sys
from pathlib import Path
expected = json.loads(Path(sys.argv[1]).read_text())
raw = Path(sys.argv[2]).read_text().strip()
digest = sys.argv[3].removeprefix("sha256:")
try:
parsed = json.loads(raw)
documents = parsed if isinstance(parsed, list) else [parsed]
except json.JSONDecodeError:
documents = [
json.loads(line) for line in raw.splitlines() if line.strip()
]
for document in documents:
encoded = document.get("payload")
if not isinstance(encoded, str):
continue
statement = json.loads(base64.b64decode(encoded))
subjects = statement.get("subject", [])
predicate = statement.get("predicate")
if (
isinstance(predicate, dict)
and isinstance(predicate.get("Data"), str)
):
predicate = json.loads(predicate["Data"])
if (
any(
item.get("digest", {}).get("sha256") == digest
for item in subjects
if isinstance(item, dict)
)
and predicate == expected
):
break
else:
raise SystemExit(
"verified schema attestation does not bind this Release"
)
PY
oras pull "${schemaOciRef}" --output /work/schema
actual_bundle_digest="$(
sha256sum /work/schema/ljwx-health-schema-bundle.tar.gz \
| awk '{print "sha256:" $1}'
)"
test "${actual_bundle_digest}" = "${schemaBundleDigest}"
tar -xzf /work/schema/ljwx-health-schema-bundle.tar.gz \
-C /work/bundle
python3 - /work/bundle/bundle-metadata.json <<'PY'
import hashlib
import json
import os
import sys
from pathlib import Path
metadata = json.loads(Path(sys.argv[1]).read_text())
digest = lambda path: (
"sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
)
production = next(
item for item in metadata["fingerprints"]
if item["profile"] == "production-pg18"
)
expected = {
"source": os.environ["sourceSha"],
"runner": os.environ["schemaRunnerImageRef"],
"manifest": os.environ["schemaManifestDigest"],
"media": os.environ["schemaBundleMediaType"],
"canonicalProduction": os.environ[
"canonicalProductionFingerprint"
],
"adoptedProduction": os.environ[
"expectedProductionFingerprint"
],
"adoptionPlan": os.environ["adoptionPlanDigest"],
"adoptionEvidence": os.environ["adoptionEvidenceDigest"],
}
actual = {
"source": metadata["source"]["sha"],
"runner": metadata["runner"]["imageRef"],
"manifest": metadata["schemaManifestDigest"],
"media": metadata["mediaType"],
"canonicalProduction": production["digest"],
"adoptedProduction": metadata["adoption"][
"expectedPostFingerprint"
],
"adoptionPlan": metadata["adoption"]["planDigest"],
"adoptionEvidence": metadata["adoption"]["catalogComparison"][
"evidenceDigest"
],
}
if actual != expected:
raise SystemExit(f"schema bundle metadata mismatch: {actual} != {expected}")
for name, key in (
("bundle-sbom.spdx.json", "sbomDigest"),
("bundle-provenance.intoto.json", "provenanceDigest"),
):
if digest(Path("/work/bundle") / name) != metadata[key]:
raise SystemExit(f"{name} digest mismatch")
adoption = metadata["adoption"]
comparison = adoption["catalogComparison"]
plan_path = Path("/work/bundle") / os.environ["adoptionPlanPath"]
evidence_path = Path("/work/bundle") / os.environ["adoptionEvidencePath"]
plan = json.loads(plan_path.read_text())
plan_digest = "sha256:" + hashlib.sha256(
json.dumps(
plan,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
if (
adoption["planPath"] != os.environ["adoptionPlanPath"]
or plan_digest != os.environ["adoptionPlanDigest"]
or comparison["evidencePath"] != os.environ["adoptionEvidencePath"]
or digest(evidence_path) != os.environ["adoptionEvidenceDigest"]
or adoption["expectedPreFingerprint"]
!= os.environ["adoptionExpectedPreFingerprint"]
or adoption["expectedPostFingerprint"]
!= os.environ["adoptionExpectedPostFingerprint"]
or plan["catalogComparison"] != comparison
):
raise SystemExit("schema bundle adoption contract mismatch")
PY
service_account=/var/run/secrets/kubernetes.io/serviceaccount
token="$(< "${service_account}/token")"
api="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}"
auth_header="Authorization: Bearer ${token}"
curl --fail --silent --show-error \
--cacert "${service_account}/ca.crt" \
-H "${auth_header}" \
"${api}/api/v1/namespaces/infra/secrets/postgres-backup-credentials" \
>/work/backup-secret.json
curl --fail --silent --show-error \
--cacert "${service_account}/ca.crt" \
-H "${auth_header}" \
"${api}/api/v1/namespaces/infra/configmaps/postgres-infra-pgbackrest" \
>/work/pgbackrest-configmap.json
jq -er '.data["minio-access-key"]' /work/backup-secret.json \
| base64 -d >/credentials/minio-access-key
jq -er '.data["minio-secret-key"]' /work/backup-secret.json \
| base64 -d >/credentials/minio-secret-key
jq -er '.data["pgbackrest.conf"]' /work/pgbackrest-configmap.json \
>/pgbackrest/pgbackrest.conf
rm -f /work/backup-secret.json /work/pgbackrest-configmap.json
unset token auth_header
chmod 0400 /credentials/minio-access-key \
/credentials/minio-secret-key
chmod 0440 /pgbackrest/pgbackrest.conf
chgrp 1001 \
/credentials/minio-access-key \
/credentials/minio-secret-key \
/pgbackrest/pgbackrest.conf
chmod 0440 \
/credentials/minio-access-key \
/credentials/minio-secret-key
export PGBACKREST_REPO1_S3_KEY
export PGBACKREST_REPO1_S3_KEY_SECRET
PGBACKREST_REPO1_S3_KEY="$(< /credentials/minio-access-key)"
PGBACKREST_REPO1_S3_KEY_SECRET="$(< /credentials/minio-secret-key)"
pgbackrest \
--config=/pgbackrest/pgbackrest.conf \
--stanza=ljwx-health \
--output=json \
info >/evidence/backup-info.json
unset PGBACKREST_REPO1_S3_KEY PGBACKREST_REPO1_S3_KEY_SECRET
jq -n \
--arg status VERIFIED \
--arg sourceSha "${sourceSha}" \
--arg releaseManifestOciRef "${releaseManifestOciRef}" \
--arg schemaOciRef "${schemaOciRef}" \
--arg schemaBundleDigest "${schemaBundleDigest}" \
--arg bindingAttestationDigest "${bindingAttestationDigest}" \
'{
status: $status,
sourceSha: $sourceSha,
releaseManifestOciRef: $releaseManifestOciRef,
schemaOciRef: $schemaOciRef,
schemaBundleDigest: $schemaBundleDigest,
bindingAttestationDigest: $bindingAttestationDigest
}' >/evidence/release-verification.json
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# shellcheck disable=SC2317,SC2329 # cleanup is invoked by the EXIT trap.
set -euo pipefail
case "${BACKUP_LABEL}" in
BACKUP_LABEL_REQUIRED|"") exit 1 ;;
*[!0-9A-Z_-]*)
echo "backup label contains unsupported characters" >&2
exit 1
;;
esac
cleanup() {
status=$?
trap - EXIT
if [[ -f /restore/postmaster.pid ]]; then
pg_ctl -D /restore -m fast -w stop >/dev/null 2>&1 || true
fi
exit "${status}"
}
trap cleanup EXIT
date +%s >/evidence/drill-started-epoch
export PGBACKREST_REPO1_S3_KEY
export PGBACKREST_REPO1_S3_KEY_SECRET
PGBACKREST_REPO1_S3_KEY="$(< /credentials/minio-access-key)"
PGBACKREST_REPO1_S3_KEY_SECRET="$(< /credentials/minio-secret-key)"
pgbackrest \
--stanza=ljwx-health \
--pg1-path=/restore \
--set="${BACKUP_LABEL}" \
--type=immediate \
--target-action=promote \
--archive-mode=off \
restore
cat >/restore/postgresql.conf <<'CONF'
data_directory = '/restore'
hba_file = '/restore/pg_hba.conf'
ident_file = '/restore/pg_ident.conf'
port = 55432
listen_addresses = '127.0.0.1'
unix_socket_directories = '/socket'
logging_collector = off
shared_preload_libraries = ''
max_connections = 200
max_locks_per_transaction = 64
max_prepared_transactions = 0
max_wal_senders = 16
max_worker_processes = 8
archive_mode = off
CONF
printf '%s\n' 'local all all trust' >/restore/pg_hba.conf
printf '%s\n' 'host all all 127.0.0.1/32 trust' >>/restore/pg_hba.conf
pg_ctl \
-D /restore \
-o "-c config_file=/restore/postgresql.conf" \
-w start
until [[ "$(
psql -v ON_ERROR_STOP=1 \
-U postgres -d postgres -h /socket -p 55432 \
-Atc 'SELECT pg_is_in_recovery()'
)" == "f" ]]; do
sleep 1
done
touch /evidence/postgres-ready
for _ in $(seq 1 2400); do
if [[ -f /evidence/restore-drill-verified ]]; then
date +%s >/evidence/drill-finished-epoch
exit 0
fi
if [[ -f /evidence/schema-error \
|| -f /evidence/application-smoke-error ]]; then
echo "schema verification or application smoke failed" >&2
exit 1
fi
sleep 1
done
echo "timed out waiting for isolated schema verification" >&2
exit 1
@@ -0,0 +1,51 @@
#!/usr/bin/env sh
# shellcheck disable=SC2317,SC2329 # cleanup is invoked by signal and EXIT traps.
set -eu
cleanup() {
status=$?
trap - EXIT INT TERM
if [ -n "${redis_pid:-}" ] \
&& kill -0 "${redis_pid}" 2>/dev/null; then
kill "${redis_pid}" 2>/dev/null || true
wait "${redis_pid}" 2>/dev/null || true
fi
exit "${status}"
}
trap cleanup EXIT INT TERM
redis-server \
--bind 127.0.0.1 \
--port 6379 \
--protected-mode no \
--save '' \
--appendonly no \
--dir /tmp &
redis_pid=$!
for _ in $(seq 1 120); do
if redis-cli -h 127.0.0.1 -p 6379 ping 2>/dev/null \
| grep -q PONG; then
touch /evidence/redis-ready
break
fi
if ! kill -0 "${redis_pid}" 2>/dev/null; then
echo "ephemeral Redis exited before readiness" >&2
exit 1
fi
sleep 1
done
test -f /evidence/redis-ready
for _ in $(seq 1 2400); do
if [ -f /evidence/application-smoke-verified ]; then
exit 0
fi
if [ -f /evidence/application-smoke-error ]; then
exit 1
fi
sleep 1
done
echo "timed out waiting for restored application smoke" >&2
exit 1
@@ -0,0 +1,73 @@
#!/usr/bin/env sh
# shellcheck disable=SC2317,SC2329 # fail is invoked by signal and EXIT traps.
set -eu
fail() {
status=$?
trap - EXIT INT TERM
touch /evidence/application-smoke-error || true
if [ -f /evidence/application.log ]; then
tail -n 120 /evidence/application.log >&2 || true
fi
if [ -n "${application_pid:-}" ] \
&& kill -0 "${application_pid}" 2>/dev/null; then
kill "${application_pid}" 2>/dev/null || true
wait "${application_pid}" 2>/dev/null || true
fi
exit "${status}"
}
trap fail EXIT INT TERM
for _ in $(seq 1 1200); do
if [ -f /evidence/schema-verified ] \
&& [ -f /evidence/redis-ready ]; then
break
fi
if [ -f /evidence/schema-error ]; then
exit 1
fi
sleep 1
done
test -f /evidence/schema-verified
test -f /evidence/redis-ready
SA_TOKEN_JWT_SECRET="$(head -c 48 /dev/urandom | base64 | tr -d '\n')"
export SA_TOKEN_JWT_SECRET
cd /app
# shellcheck disable=SC2086 # JAVA_OPTS is an intentional JVM argument list.
java ${JAVA_OPTS:-} -jar app.jar >/evidence/application.log 2>&1 &
application_pid=$!
health_url=http://127.0.0.1:19998/actuator/health/readiness
for _ in $(seq 1 300); do
if wget -q -O /evidence/application-readiness.json "${health_url}"; then
break
fi
if ! kill -0 "${application_pid}" 2>/dev/null; then
echo "restored application exited before readiness" >&2
exit 1
fi
sleep 1
done
test -s /evidence/application-readiness.json
grep -q '"status"[[:space:]]*:[[:space:]]*"UP"' \
/evidence/application-readiness.json
finished="$(date +%s)"
started="$(cat /evidence/drill-started-epoch)"
cat >/evidence/application-smoke.json <<JSON
{
"status": "APPLICATION_SMOKE_PASS",
"imageRef": "${LJWX_BOOT_IMAGE_REF}",
"healthUrl": "${health_url}",
"productionApplicationSecret": false,
"redis": "ephemeral-loopback",
"drillFinishedEpoch": ${finished},
"rtoSeconds": $((finished - started))
}
JSON
touch /evidence/application-smoke-verified
kill "${application_pid}" 2>/dev/null || true
wait "${application_pid}" 2>/dev/null || true
application_pid=0
trap - EXIT INT TERM
@@ -0,0 +1,252 @@
#!/usr/bin/env bash
# shellcheck disable=SC2154 # Release fields are injected by envFrom.
set -euo pipefail
fail() {
status=$?
trap - ERR
touch /evidence/schema-error || true
exit "${status}"
}
trap fail ERR
case "${RESTORE_MODE}" in
pre-adoption|post-adoption) ;;
*) echo "unsupported RESTORE_MODE=${RESTORE_MODE}" >&2; exit 1 ;;
esac
test "${BACKUP_LABEL}" != "BACKUP_LABEL_REQUIRED"
test -n "${sourceSha}"
test -n "${expectedProductionFingerprint}"
test "${adoptionExpectedPostFingerprint}" = "${expectedProductionFingerprint}"
for _ in $(seq 1 1200); do
[[ -f /evidence/postgres-ready ]] && break
sleep 1
done
test -f /evidence/postgres-ready
cd /work/bundle
export PGHOST=/socket
export PGPORT=55432
export PGDATABASE=ljwx_health
export LJWX_SCHEMA_SOURCE_SHA="${sourceSha}"
export LJWX_SCHEMA_ALLOW_PRODUCTION=true
DB_MIGRATOR_PASSWORD="$(
head -c 48 /dev/urandom | base64 | tr -d '\n'
)"
export DB_MIGRATOR_PASSWORD
export LJWX_SCHEMA_MIGRATOR_PASSWORD="${DB_MIGRATOR_PASSWORD}"
adoption_plan=ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.json
export PGUSER=postgres
unset PGPASSWORD
if [[ "${RESTORE_MODE}" == "pre-adoption" ]]; then
ljwx-boot/scripts/schema-adopt.sh \
--profile production-pg18 \
--target production \
--expected-database ljwx_health \
--database ljwx_health \
--plan "${adoption_plan}" \
--output /evidence/adoption-evidence.json
else
printf "%s\n" \
"ALTER ROLE ljwx_migrator PASSWORD :'migrator_password';" \
| psql -v ON_ERROR_STOP=1 \
-v "migrator_password=${DB_MIGRATOR_PASSWORD}" \
-d ljwx_health >/dev/null
fi
export PGUSER=ljwx_migrator
export PGPASSWORD="${DB_MIGRATOR_PASSWORD}"
unset LJWX_SCHEMA_MIGRATOR_PASSWORD
ljwx-boot/scripts/schema-upgrade.sh \
--profile production-pg18 \
--target production \
--expected-database ljwx_health \
--database ljwx_health \
--adoption-plan "${adoption_plan}" \
--output /evidence/upgrade-evidence.json
ljwx-boot/scripts/schema-verify.sh \
--profile production-pg18 \
--target production \
--expected-database ljwx_health \
--database ljwx_health \
--output /evidence/verify-evidence.json
test "$(
jq -r .logicalFingerprint /evidence/verify-evidence.json
)" = "${expectedProductionFingerprint}"
export PGUSER=postgres
unset PGPASSWORD
psql -v ON_ERROR_STOP=1 -At <<'SQL' >/evidence/key-row-counts.json
SELECT jsonb_build_object(
'sys_org', (SELECT count(*) FROM public.sys_org),
'sys_user', (SELECT count(*) FROM public.sys_user),
't_alert_info', (SELECT count(*) FROM public.t_alert_info),
't_user_health_data', (
SELECT count(*) FROM public.t_user_health_data
)
)::text;
SQL
psql -v ON_ERROR_STOP=1 -At <<'SQL' >/evidence/catalog-inventory.json
SELECT jsonb_build_object(
'tables', (
SELECT count(*) FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
),
'columns', (
SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'public'
),
'sequences', (
SELECT count(*) FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'S'
),
'rlsTables', (
SELECT count(*) FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relrowsecurity
),
'forcedRlsTables', (
SELECT count(*) FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relforcerowsecurity
),
'policies', (
SELECT count(*) FROM pg_policy p
JOIN pg_class c ON c.oid = p.polrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
),
'extensions', (
SELECT jsonb_object_agg(extname, extversion ORDER BY extname)
FROM pg_extension
),
'timescaleExtensionPresent', EXISTS (
SELECT 1 FROM pg_extension WHERE extname = 'timescaledb'
),
'timescaleHypertables', CASE WHEN EXISTS (
SELECT 1 FROM pg_extension WHERE extname = 'timescaledb'
) THEN NULL ELSE 0 END,
'timescaleContinuousAggregates', CASE WHEN EXISTS (
SELECT 1 FROM pg_extension WHERE extname = 'timescaledb'
) THEN NULL ELSE 0 END
)::text;
SQL
backup_stop="$(
jq -er --arg label "${BACKUP_LABEL}" \
'.[0].backup[] | select(.label == $label) | .timestamp.stop' \
/evidence/backup-info.json
)"
source_database="$(
jq -c '.[0].db[0] | {
id,
repoKey: .["repo-key"],
systemId: .["system-id"],
version
}' /evidence/backup-info.json
)"
backup_identity="$(
jq -c --arg label "${BACKUP_LABEL}" '
.[0].backup[]
| select(.label == $label)
| {
label,
type,
error,
timestamp,
archive,
lsn,
prior,
reference,
info,
database
}
' /evidence/backup-info.json
)"
repository_status="$(
jq -c '.[0].repo | map({key, status, cipher})' \
/evidence/backup-info.json
)"
finished="$(date +%s)"
started="$(< /evidence/drill-started-epoch)"
jq -n \
--arg status "RESTORE_SCHEMA_PASS" \
--arg mode "${RESTORE_MODE}" \
--arg backupLabel "${BACKUP_LABEL}" \
--arg sourceSha "${sourceSha}" \
--arg schemaOciRef "${schemaOciRef}" \
--arg fingerprint "${expectedProductionFingerprint}" \
--argjson backupStopEpoch "${backup_stop}" \
--argjson drillStartedEpoch "${started}" \
--argjson drillFinishedEpoch "${finished}" \
--argjson keyRowCounts "$(< /evidence/key-row-counts.json)" \
--argjson catalog "$(< /evidence/catalog-inventory.json)" \
--argjson sourceDatabase "${source_database}" \
--argjson backupIdentity "${backup_identity}" \
--argjson repositoryStatus "${repository_status}" \
'{
status: $status,
mode: $mode,
backupLabel: $backupLabel,
sourceSha: $sourceSha,
schemaOciRef: $schemaOciRef,
normalizedFingerprint: $fingerprint,
sourceDatabase: $sourceDatabase,
backupIdentity: $backupIdentity,
backupRepository: {
implementation: "pgBackRest",
location: "S3-compatible MinIO repository 1 (credentials omitted)",
checksumVerification: "pgBackRest restore block checksums",
status: $repositoryStatus
},
backupStopEpoch: $backupStopEpoch,
drillStartedEpoch: $drillStartedEpoch,
drillFinishedEpoch: $drillFinishedEpoch,
rpoSecondsAtDrillStart: (
$drillStartedEpoch - $backupStopEpoch
),
rtoSeconds: ($drillFinishedEpoch - $drillStartedEpoch),
keyRowCounts: $keyRowCounts,
catalog: $catalog,
isolation: {
storage: "emptyDir",
sharedProductionPvc: false,
productionApplicationSecret: false,
productionDatabaseEgress: false
}
}' >/evidence/restore-drill-result.json
touch /evidence/schema-verified
for _ in $(seq 1 600); do
if [[ -f /evidence/application-smoke-verified ]]; then
break
fi
if [[ -f /evidence/application-smoke-error ]]; then
exit 1
fi
sleep 1
done
test -s /evidence/application-smoke.json
application_smoke="$(< /evidence/application-smoke.json)"
jq \
--arg status RESTORE_DRILL_PASS \
--argjson applicationSmoke "${application_smoke}" \
'.status = $status
| .drillFinishedEpoch = $applicationSmoke.drillFinishedEpoch
| .rtoSeconds = $applicationSmoke.rtoSeconds
| .applicationSmoke = (
$applicationSmoke
| del(.drillFinishedEpoch, .rtoSeconds)
)' \
/evidence/restore-drill-result.json \
>/evidence/restore-drill-result.final.json
mv /evidence/restore-drill-result.final.json \
/evidence/restore-drill-result.json
jq -c . /evidence/restore-drill-result.json
touch /evidence/restore-drill-verified
trap - ERR
@@ -1,201 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ljwx-health-alert-relay
labels:
app.kubernetes.io/name: ljwx-health-alert-relay
app.kubernetes.io/component: notifications
data:
server.mjs: |
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
const port = Number.parseInt(process.env.PORT || "8080", 10);
const appId = process.env.FEISHU_APP_ID;
const appSecret = process.env.FEISHU_APP_SECRET;
const receiveId = process.env.FEISHU_ONCALL_OPEN_ID;
const apiBase = process.env.FEISHU_API_BASE || "https://open.feishu.cn/open-apis";
const maxBodyBytes = 1024 * 1024;
const metrics = {
accepted: 0,
delivered: 0,
failed: 0,
resolved: 0,
readChecks: 0,
readConfirmed: 0,
lastDelivery: 0,
};
let tokenCache = { value: "", expiresAt: 0 };
function log(level, event, fields = {}) {
process.stdout.write(`${JSON.stringify({ ts: new Date().toISOString(), level, event, ...fields })}\n`);
}
function json(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"cache-control": "no-store",
});
res.end(payload);
}
async function request(path, options = {}) {
const response = await fetch(`${apiBase}${path}`, {
...options,
headers: { "content-type": "application/json; charset=utf-8", ...(options.headers || {}) },
signal: AbortSignal.timeout(10000),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || (typeof data.code === "number" && data.code !== 0)) {
const code = data.code ?? response.status;
const message = data.msg || data.message || response.statusText;
throw new Error(`Feishu API rejected request (${code}): ${message}`);
}
return data;
}
async function tenantToken() {
const now = Date.now();
if (tokenCache.value && tokenCache.expiresAt > now + 60000) return tokenCache.value;
const data = await request("/auth/v3/tenant_access_token/internal", {
method: "POST",
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
tokenCache = {
value: data.tenant_access_token,
expiresAt: now + Math.max(60, Number(data.expire || 7200)) * 1000,
};
return tokenCache.value;
}
function summarizeAlert(alert) {
const labels = alert.labels || {};
const annotations = alert.annotations || {};
const name = labels.alertname || "UnnamedAlert";
const service = labels.service || labels.deployment || labels.namespace || "unknown";
const summary = annotations.summary || annotations.description || "无摘要";
const fingerprint = alert.fingerprint || "unknown";
return `• ${name} | ${service}\n ${summary}\n fingerprint=${fingerprint}`;
}
function renderMessage(payload, traceId) {
const status = payload.status === "resolved" ? "resolved" : "firing";
const alerts = Array.isArray(payload.alerts) ? payload.alerts : [];
const title = status === "resolved" ? "✅ LJWX Health 告警已恢复" : "🚨 LJWX Health 生产告警";
const severity = payload.commonLabels?.severity || alerts[0]?.labels?.severity || "unknown";
const detail = alerts.slice(0, 20).map(summarizeAlert).join("\n");
const omitted = alerts.length > 20 ? `\n…另有 ${alerts.length - 20} 条告警` : "";
return `${title}\n状态: ${status}\n级别: ${severity}\n数量: ${alerts.length}\ntrace_id: ${traceId}\n\n${detail || "无告警明细"}${omitted}`;
}
async function sendMessage(text) {
const token = await tenantToken();
const data = await request("/im/v1/messages?receive_id_type=open_id", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({
receive_id: receiveId,
msg_type: "text",
content: JSON.stringify({ text }),
}),
});
return data.data?.message_id;
}
async function readReceipt(messageId) {
const token = await tenantToken();
const data = await request(`/im/v1/messages/${encodeURIComponent(messageId)}/read_users?user_id_type=open_id`, {
headers: { authorization: `Bearer ${token}` },
});
const items = data.data?.items || [];
return { read: items.some((item) => item.user_id === receiveId), readCount: items.length };
}
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > maxBodyBytes) throw new Error("request body exceeds 1 MiB");
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function prometheusMetrics() {
return [
"# HELP ljwx_alert_relay_notifications_total Alertmanager webhook notifications accepted by the relay.",
"# TYPE ljwx_alert_relay_notifications_total counter",
`ljwx_alert_relay_notifications_total ${metrics.accepted}`,
"# HELP ljwx_alert_relay_deliveries_total Feishu delivery attempts by result.",
"# TYPE ljwx_alert_relay_deliveries_total counter",
`ljwx_alert_relay_deliveries_total{result=\"success\"} ${metrics.delivered}`,
`ljwx_alert_relay_deliveries_total{result=\"failure\"} ${metrics.failed}`,
"# HELP ljwx_alert_relay_resolved_total Resolved Alertmanager notifications delivered.",
"# TYPE ljwx_alert_relay_resolved_total counter",
`ljwx_alert_relay_resolved_total ${metrics.resolved}`,
"# HELP ljwx_alert_relay_read_checks_total Feishu read receipt checks by result.",
"# TYPE ljwx_alert_relay_read_checks_total counter",
`ljwx_alert_relay_read_checks_total{result=\"read\"} ${metrics.readConfirmed}`,
`ljwx_alert_relay_read_checks_total{result=\"not_read\"} ${Math.max(0, metrics.readChecks - metrics.readConfirmed)}`,
"# HELP ljwx_alert_relay_last_delivery_timestamp_seconds Unix timestamp of the latest successful delivery.",
"# TYPE ljwx_alert_relay_last_delivery_timestamp_seconds gauge",
`ljwx_alert_relay_last_delivery_timestamp_seconds ${metrics.lastDelivery}`,
"",
].join("\n");
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", "http://relay.local");
if (req.method === "GET" && url.pathname === "/healthz") return json(res, 200, { status: "ok" });
if (req.method === "GET" && url.pathname === "/readyz") {
const ready = Boolean(appId && appSecret && receiveId);
return json(res, ready ? 200 : 503, { status: ready ? "ready" : "missing_required_configuration" });
}
if (req.method === "GET" && url.pathname === "/metrics") {
const body = prometheusMetrics();
res.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8" });
return res.end(body);
}
if (req.method === "GET" && url.pathname === "/receipts") {
const messageId = url.searchParams.get("message_id") || "";
if (!/^[A-Za-z0-9_-]{8,128}$/.test(messageId)) return json(res, 400, { error: "invalid message_id" });
const receipt = await readReceipt(messageId);
metrics.readChecks += 1;
if (receipt.read) metrics.readConfirmed += 1;
log("info", "read_receipt_checked", { messageId, ...receipt });
return json(res, 200, { messageId, ...receipt });
}
if (req.method === "POST" && url.pathname === "/alerts") {
const payload = await readBody(req);
const traceId = randomUUID();
metrics.accepted += 1;
const messageId = await sendMessage(renderMessage(payload, traceId));
metrics.delivered += 1;
metrics.lastDelivery = Math.floor(Date.now() / 1000);
if (payload.status === "resolved") metrics.resolved += 1;
log("info", "alert_delivered", {
traceId,
messageId,
status: payload.status,
drillId: payload.commonLabels?.drill_id,
alertCount: Array.isArray(payload.alerts) ? payload.alerts.length : 0,
alertNames: (payload.alerts || []).map((alert) => alert.labels?.alertname).filter(Boolean),
fingerprints: (payload.alerts || []).map((alert) => alert.fingerprint).filter(Boolean),
});
return json(res, 202, { accepted: true, traceId, messageId });
}
return json(res, 404, { error: "not_found" });
} catch (error) {
metrics.failed += 1;
log("error", "request_failed", { method: req.method, path: req.url, error: error.message });
return json(res, 502, { accepted: false, error: error.message });
}
});
server.requestTimeout = 15000;
server.headersTimeout = 5000;
server.listen(port, "0.0.0.0", () => log("info", "relay_started", { port }));
+43
View File
@@ -21,6 +21,21 @@ spec:
app.kubernetes.io/component: notifications
spec:
automountServiceAccountToken: false
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: topology.kubernetes.io/zone
labelSelector:
matchLabels:
app.kubernetes.io/name: ljwx-health-alert-relay
- weight: 50
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: ljwx-health-alert-relay
securityContext:
runAsNonRoot: true
runAsUser: 1000
@@ -48,6 +63,24 @@ spec:
secretKeyRef:
name: openclaw-extra-env
key: FEISHU_ONCALL_OPEN_ID
- name: FEISHU_BACKUP_ONCALL_OPEN_ID
valueFrom:
secretKeyRef:
name: openclaw-extra-env
key: FEISHU_BACKUP_ONCALL_OPEN_ID
optional: true
- name: ALERTMANAGER_BASE_URL
value: http://alertmanager-operated.observability.svc.cluster.local:9093
- name: ALERT_RELAY_CRITICAL_SLA_SECONDS
value: "300"
- name: ALERT_RELAY_WARNING_SLA_SECONDS
value: "900"
- name: ALERT_RELAY_ACK_SILENCE_SECONDS
value: "7200"
- name: ALERT_RELAY_PRIMARY_OWNER
value: platform-oncall-primary
- name: ALERT_RELAY_BACKUP_OWNER
value: platform-oncall-backup
ports:
- name: http
containerPort: 8080
@@ -185,3 +218,13 @@ spec:
ports:
- protocol: TCP
port: 443
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: observability
podSelector:
matchLabels:
app.kubernetes.io/name: alertmanager
ports:
- protocol: TCP
port: 9093
+576
View File
@@ -0,0 +1,576 @@
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { basename } from "node:path";
const MAX_BODY_BYTES = 1024 * 1024;
const TOKEN_VERSION = 1;
const TOKEN_MAX_AGE_SECONDS = 24 * 60 * 60;
const MATCHER_LABELS = [
"alertname",
"severity",
"cluster",
"service",
"namespace",
"category",
"drill_id",
];
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
function integer(value, fallback, minimum, maximum) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, parsed));
}
export function loadConfig(env = process.env) {
return {
port: integer(env.PORT, 8080, 1, 65535),
appId: env.FEISHU_APP_ID || "",
appSecret: env.FEISHU_APP_SECRET || "",
receiveId: env.FEISHU_ONCALL_OPEN_ID || "",
backupReceiveId: env.FEISHU_BACKUP_ONCALL_OPEN_ID || "",
apiBase: env.FEISHU_API_BASE || "https://open.feishu.cn/open-apis",
alertmanagerBase:
env.ALERTMANAGER_BASE_URL ||
"http://alertmanager-operated.observability.svc.cluster.local:9093",
criticalSlaSeconds: integer(env.ALERT_RELAY_CRITICAL_SLA_SECONDS, 300, 5, 86400),
warningSlaSeconds: integer(env.ALERT_RELAY_WARNING_SLA_SECONDS, 900, 5, 86400),
acknowledgementSilenceSeconds: integer(
env.ALERT_RELAY_ACK_SILENCE_SECONDS,
7200,
60,
86400,
),
primaryOwner: env.ALERT_RELAY_PRIMARY_OWNER || "platform-oncall-primary",
backupOwner: env.ALERT_RELAY_BACKUP_OWNER || "platform-oncall-backup",
};
}
function encode(value) {
return Buffer.from(value, "utf8").toString("base64url");
}
function decode(value) {
return Buffer.from(value, "base64url").toString("utf8");
}
function signature(payload, secret) {
return createHmac("sha256", secret)
.update("ljwx-health-alert-ack-v1\0")
.update(payload)
.digest("base64url");
}
export function signIncidentToken(incident, secret) {
if (!secret) throw new Error("acknowledgement signing secret is missing");
const payload = encode(JSON.stringify(incident));
return `${payload}.${signature(payload, secret)}`;
}
export function verifyIncidentToken(token, secret, now = Date.now()) {
if (!secret) throw new HttpError(503, "acknowledgement signing secret is missing");
const parts = String(token || "").split(".");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new HttpError(400, "invalid acknowledgement token");
}
const expected = Buffer.from(signature(parts[0], secret), "utf8");
const actual = Buffer.from(parts[1], "utf8");
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new HttpError(403, "invalid acknowledgement token signature");
}
let incident;
try {
incident = JSON.parse(decode(parts[0]));
} catch {
throw new HttpError(400, "invalid acknowledgement token payload");
}
if (
incident?.v !== TOKEN_VERSION ||
typeof incident.traceId !== "string" ||
!Array.isArray(incident.matchers) ||
incident.matchers.length === 0 ||
!Number.isFinite(incident.issuedAt)
) {
throw new HttpError(400, "incomplete acknowledgement token payload");
}
const ageSeconds = Math.floor(now / 1000) - incident.issuedAt;
if (ageSeconds < -60 || ageSeconds > TOKEN_MAX_AGE_SECONDS) {
throw new HttpError(410, "acknowledgement token expired");
}
return incident;
}
export function incidentMatchers(payload) {
const commonLabels = payload?.commonLabels || {};
const firstLabels = Array.isArray(payload?.alerts) ? payload.alerts[0]?.labels || {} : {};
const matchers = MATCHER_LABELS.flatMap((name) => {
const raw = commonLabels[name] ?? firstLabels[name];
if (raw === undefined || raw === null || String(raw).length === 0) return [];
return [{ name, value: String(raw), isRegex: false }];
});
if (!matchers.some((matcher) => matcher.name === "alertname")) {
throw new HttpError(400, "alertname is required for acknowledgement");
}
return matchers;
}
export function incidentTraceId(payload) {
const alerts = Array.isArray(payload?.alerts) ? payload.alerts : [];
const identity = alerts
.map((alert) => ({
fingerprint: String(alert?.fingerprint || ""),
startsAt: String(alert?.startsAt || ""),
alertname: String(alert?.labels?.alertname || ""),
}))
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
const material = JSON.stringify({
groupKey: String(payload?.groupKey || ""),
receiver: String(payload?.receiver || ""),
alerts: identity,
});
return `incident-${createHash("sha256").update(material).digest("hex").slice(0, 32)}`;
}
export function acknowledgementSlaSeconds(severity, config) {
return String(severity || "").toLowerCase() === "critical"
? config.criticalSlaSeconds
: config.warningSlaSeconds;
}
function summarizeAlert(alert) {
const labels = alert.labels || {};
const annotations = alert.annotations || {};
const name = labels.alertname || "UnnamedAlert";
const service = labels.service || labels.deployment || labels.namespace || "unknown";
const summary = annotations.summary || annotations.description || "无摘要";
const fingerprint = alert.fingerprint || "unknown";
return `• ${name} | ${service}\n ${summary}\n fingerprint=${fingerprint}`;
}
function renderMessage(payload, incident, acknowledgementToken, config) {
const status = payload.status === "resolved" ? "resolved" : "firing";
const alerts = Array.isArray(payload.alerts) ? payload.alerts : [];
const title = status === "resolved" ? "✅ LJWX Health 告警已恢复" : "🚨 LJWX Health 生产告警";
const severity = payload.commonLabels?.severity || alerts[0]?.labels?.severity || "unknown";
const detail = alerts.slice(0, 20).map(summarizeAlert).join("\n");
const omitted = alerts.length > 20 ? `\n…另有 ${alerts.length - 20} 条告警` : "";
const acknowledgement = status === "firing"
? `\n确认 SLA: ${acknowledgementSlaSeconds(severity, config)} 秒\n值班角色: ${config.primaryOwner}\nincident_id: ${incident.traceId}\nack_token: ${acknowledgementToken}`
: `\nincident_id: ${incident.traceId}`;
return `${title}\n状态: ${status}\n级别: ${severity}\n数量: ${alerts.length}${acknowledgement}\n\n${detail || "无告警明细"}${omitted}`;
}
function json(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"cache-control": "no-store",
});
res.end(payload);
}
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new HttpError(413, "request body exceeds 1 MiB");
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new HttpError(400, "request body must be valid JSON");
}
}
export function createRelay(config = loadConfig(), dependencies = {}) {
const fetchImpl = dependencies.fetchImpl || globalThis.fetch;
const now = dependencies.now || (() => Date.now());
const schedule = dependencies.schedule || ((callback, delay) => setTimeout(callback, delay));
const log = dependencies.log || ((level, event, fields = {}) => {
process.stdout.write(`${JSON.stringify({ ts: new Date().toISOString(), level, event, ...fields })}\n`);
});
const metrics = {
accepted: 0,
delivered: 0,
failed: 0,
resolved: 0,
readChecks: 0,
readConfirmed: 0,
explicitAcknowledgements: 0,
acknowledgementFailures: 0,
escalationSuccess: 0,
escalationFailure: 0,
lastDelivery: 0,
lastAcknowledgement: 0,
lastEscalation: 0,
};
let tokenCache = { value: "", expiresAt: 0 };
async function request(base, path, options = {}) {
const response = await fetchImpl(`${base}${path}`, {
...options,
headers: { "content-type": "application/json; charset=utf-8", ...(options.headers || {}) },
signal: AbortSignal.timeout(10000),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || (typeof data.code === "number" && data.code !== 0)) {
const code = data.code ?? response.status;
const message = data.msg || data.message || response.statusText;
throw new Error(`remote API rejected request (${code}): ${message}`);
}
return data;
}
async function tenantToken() {
const current = now();
if (tokenCache.value && tokenCache.expiresAt > current + 60000) return tokenCache.value;
const data = await request(config.apiBase, "/auth/v3/tenant_access_token/internal", {
method: "POST",
body: JSON.stringify({ app_id: config.appId, app_secret: config.appSecret }),
});
tokenCache = {
value: data.tenant_access_token,
expiresAt: current + Math.max(60, Number(data.expire || 7200)) * 1000,
};
return tokenCache.value;
}
async function sendMessage(text, target = config.receiveId) {
const token = await tenantToken();
const data = await request(config.apiBase, "/im/v1/messages?receive_id_type=open_id", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({
receive_id: target,
msg_type: "text",
content: JSON.stringify({ text }),
}),
});
const messageId = data.data?.message_id;
if (!/^[A-Za-z0-9_-]{8,128}$/.test(String(messageId || ""))) {
throw new Error("Feishu accepted the request without a valid message_id");
}
return messageId;
}
async function readReceipt(messageId) {
const token = await tenantToken();
const data = await request(
config.apiBase,
`/im/v1/messages/${encodeURIComponent(messageId)}/read_users?user_id_type=open_id`,
{ headers: { authorization: `Bearer ${token}` } },
);
const items = data.data?.items || [];
return { read: items.some((item) => item.user_id === config.receiveId), readCount: items.length };
}
async function silences() {
const data = await request(config.alertmanagerBase, "/api/v2/silences");
return Array.isArray(data) ? data : [];
}
async function findAcknowledgement(traceId) {
const marker = `trace_id=${traceId}`;
const found = (await silences()).filter(
(silence) =>
["active", "pending"].includes(silence?.status?.state) &&
String(silence?.comment || "").split("; ").includes(marker),
);
return found.sort((left, right) => String(right.startsAt).localeCompare(String(left.startsAt)))[0] || null;
}
async function acknowledge(incident, operator) {
const existing = await findAcknowledgement(incident.traceId);
if (existing) {
return {
created: false,
silenceId: existing.id,
acknowledgedAt: existing.startsAt,
traceId: incident.traceId,
fingerprints: incident.fingerprints,
};
}
const startedAt = new Date(now());
const endsAt = new Date(startedAt.getTime() + config.acknowledgementSilenceSeconds * 1000);
const comment = [
"ljwx-health acknowledgement",
`trace_id=${incident.traceId}`,
`fingerprints=${(incident.fingerprints || []).join(",") || "unknown"}`,
].join("; ");
const result = await request(config.alertmanagerBase, "/api/v2/silences", {
method: "POST",
body: JSON.stringify({
matchers: incident.matchers,
startsAt: startedAt.toISOString(),
endsAt: endsAt.toISOString(),
createdBy: operator,
comment,
}),
});
metrics.explicitAcknowledgements += 1;
metrics.lastAcknowledgement = Math.floor(now() / 1000);
log("info", "alert_acknowledged", {
traceId: incident.traceId,
silenceId: result.silenceID,
operator,
fingerprints: incident.fingerprints,
acknowledgedAt: startedAt.toISOString(),
});
return {
created: true,
silenceId: result.silenceID,
acknowledgedAt: startedAt.toISOString(),
traceId: incident.traceId,
fingerprints: incident.fingerprints,
};
}
async function escalateAfterSla(incident, messageId, renderedMessage, severity) {
try {
if (await findAcknowledgement(incident.traceId)) return;
const receipt = await readReceipt(messageId);
metrics.readChecks += 1;
if (receipt.read) {
metrics.readConfirmed += 1;
log("warn", "alert_read_without_acknowledgement", {
traceId: incident.traceId,
messageId,
readCount: receipt.readCount,
fingerprints: incident.fingerprints,
});
}
if (!config.backupReceiveId || config.backupReceiveId === config.receiveId) {
metrics.escalationFailure += 1;
log("error", "alert_escalation_unconfigured", {
traceId: incident.traceId,
messageId,
severity,
backupOwner: config.backupOwner,
fingerprints: incident.fingerprints,
});
return;
}
const escalationMessageId = await sendMessage(
`⏰ 显式确认 SLA 超时,升级至 ${config.backupOwner}\n原消息已读: ${receipt.read ? "是" : "否"}\n\n${renderedMessage}`,
config.backupReceiveId,
);
metrics.escalationSuccess += 1;
metrics.lastEscalation = Math.floor(now() / 1000);
log("warn", "alert_escalated", {
traceId: incident.traceId,
messageId,
escalationMessageId,
severity,
backupOwner: config.backupOwner,
fingerprints: incident.fingerprints,
});
} catch (error) {
metrics.escalationFailure += 1;
log("error", "alert_escalation_failed", {
traceId: incident.traceId,
messageId,
severity,
error: error.message,
fingerprints: incident.fingerprints,
});
}
}
function scheduleEscalation(incident, messageId, renderedMessage, severity) {
const delay = acknowledgementSlaSeconds(severity, config) * 1000;
const timer = schedule(
() => escalateAfterSla(incident, messageId, renderedMessage, severity),
delay,
);
if (typeof timer?.unref === "function") timer.unref();
}
function prometheusMetrics() {
return [
"# HELP ljwx_alert_relay_notifications_total Alertmanager webhook notifications accepted by the relay.",
"# TYPE ljwx_alert_relay_notifications_total counter",
`ljwx_alert_relay_notifications_total ${metrics.accepted}`,
"# HELP ljwx_alert_relay_deliveries_total Feishu delivery attempts by result.",
"# TYPE ljwx_alert_relay_deliveries_total counter",
`ljwx_alert_relay_deliveries_total{result=\"success\"} ${metrics.delivered}`,
`ljwx_alert_relay_deliveries_total{result=\"failure\"} ${metrics.failed}`,
"# HELP ljwx_alert_relay_resolved_total Resolved Alertmanager notifications delivered.",
"# TYPE ljwx_alert_relay_resolved_total counter",
`ljwx_alert_relay_resolved_total ${metrics.resolved}`,
"# HELP ljwx_alert_relay_read_checks_total Feishu read receipt checks by result.",
"# TYPE ljwx_alert_relay_read_checks_total counter",
`ljwx_alert_relay_read_checks_total{result=\"read\"} ${metrics.readConfirmed}`,
`ljwx_alert_relay_read_checks_total{result=\"not_read\"} ${Math.max(0, metrics.readChecks - metrics.readConfirmed)}`,
"# HELP ljwx_alert_relay_acknowledgements_total Explicit Alertmanager-backed acknowledgements by result.",
"# TYPE ljwx_alert_relay_acknowledgements_total counter",
`ljwx_alert_relay_acknowledgements_total{result=\"success\"} ${metrics.explicitAcknowledgements}`,
`ljwx_alert_relay_acknowledgements_total{result=\"failure\"} ${metrics.acknowledgementFailures}`,
"# HELP ljwx_alert_relay_escalations_total SLA escalation attempts by result.",
"# TYPE ljwx_alert_relay_escalations_total counter",
`ljwx_alert_relay_escalations_total{result=\"success\"} ${metrics.escalationSuccess}`,
`ljwx_alert_relay_escalations_total{result=\"failure\"} ${metrics.escalationFailure}`,
"# HELP ljwx_alert_relay_escalation_configured Whether a distinct backup on-call target is configured.",
"# TYPE ljwx_alert_relay_escalation_configured gauge",
`ljwx_alert_relay_escalation_configured ${config.backupReceiveId && config.backupReceiveId !== config.receiveId ? 1 : 0}`,
"# HELP ljwx_alert_relay_last_delivery_timestamp_seconds Unix timestamp of the latest successful delivery.",
"# TYPE ljwx_alert_relay_last_delivery_timestamp_seconds gauge",
`ljwx_alert_relay_last_delivery_timestamp_seconds ${metrics.lastDelivery}`,
"# HELP ljwx_alert_relay_last_acknowledgement_timestamp_seconds Unix timestamp of the latest acknowledgement.",
"# TYPE ljwx_alert_relay_last_acknowledgement_timestamp_seconds gauge",
`ljwx_alert_relay_last_acknowledgement_timestamp_seconds ${metrics.lastAcknowledgement}`,
"# HELP ljwx_alert_relay_last_escalation_timestamp_seconds Unix timestamp of the latest successful escalation.",
"# TYPE ljwx_alert_relay_last_escalation_timestamp_seconds gauge",
`ljwx_alert_relay_last_escalation_timestamp_seconds ${metrics.lastEscalation}`,
"",
].join("\n");
}
async function handler(req, res) {
try {
const url = new URL(req.url || "/", "http://relay.local");
if (req.method === "GET" && url.pathname === "/healthz") return json(res, 200, { status: "ok" });
if (req.method === "GET" && url.pathname === "/readyz") {
const ready = Boolean(config.appId && config.appSecret && config.receiveId);
return json(res, ready ? 200 : 503, {
status: ready ? "ready" : "missing_required_configuration",
escalationConfigured: Boolean(
config.backupReceiveId && config.backupReceiveId !== config.receiveId,
),
owners: { primary: config.primaryOwner, backup: config.backupOwner },
acknowledgementSlaSeconds: {
critical: config.criticalSlaSeconds,
warning: config.warningSlaSeconds,
},
});
}
if (req.method === "GET" && url.pathname === "/metrics") {
const body = prometheusMetrics();
res.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8" });
return res.end(body);
}
if (req.method === "GET" && url.pathname === "/receipts") {
const messageId = url.searchParams.get("message_id") || "";
if (!/^[A-Za-z0-9_-]{8,128}$/.test(messageId)) {
throw new HttpError(400, "invalid message_id");
}
const receipt = await readReceipt(messageId);
metrics.readChecks += 1;
if (receipt.read) metrics.readConfirmed += 1;
log("info", "read_receipt_checked", { messageId, ...receipt });
return json(res, 200, { messageId, ...receipt });
}
if (req.method === "POST" && url.pathname === "/acknowledgements/status") {
const body = await readBody(req);
const incident = verifyIncidentToken(
body.token,
config.appSecret,
now(),
);
const acknowledgement = await findAcknowledgement(incident.traceId);
return json(res, 200, {
acknowledged: Boolean(acknowledgement),
traceId: incident.traceId,
fingerprints: incident.fingerprints,
silenceId: acknowledgement?.id || null,
acknowledgedAt: acknowledgement?.startsAt || null,
acknowledgedBy: acknowledgement?.createdBy || null,
});
}
if (req.method === "POST" && url.pathname === "/acknowledgements") {
const body = await readBody(req);
if (!/^[A-Za-z0-9._@-]{2,128}$/.test(String(body.operator || ""))) {
throw new HttpError(400, "operator must be a stable non-secret identifier");
}
const incident = verifyIncidentToken(body.token, config.appSecret, now());
const result = await acknowledge(incident, body.operator);
return json(res, result.created ? 201 : 200, result);
}
if (req.method === "POST" && url.pathname === "/alerts") {
const payload = await readBody(req);
const alerts = Array.isArray(payload.alerts) ? payload.alerts : [];
if (alerts.length === 0) throw new HttpError(400, "alerts must not be empty");
const traceId = incidentTraceId(payload);
const severity = payload.commonLabels?.severity || alerts[0]?.labels?.severity || "warning";
const incident = {
v: TOKEN_VERSION,
traceId,
issuedAt: Math.floor(now() / 1000),
matchers: incidentMatchers(payload),
fingerprints: alerts.map((alert) => alert.fingerprint).filter(Boolean).slice(0, 50),
};
const acknowledgementToken = signIncidentToken(incident, config.appSecret);
const renderedMessage = renderMessage(payload, incident, acknowledgementToken, config);
metrics.accepted += 1;
const messageId = await sendMessage(renderedMessage);
metrics.delivered += 1;
metrics.lastDelivery = Math.floor(now() / 1000);
if (payload.status === "resolved") metrics.resolved += 1;
log("info", "alert_delivered", {
traceId,
messageId,
status: payload.status,
drillId: payload.commonLabels?.drill_id,
alertCount: alerts.length,
alertNames: alerts.map((alert) => alert.labels?.alertname).filter(Boolean),
fingerprints: incident.fingerprints,
acknowledgementSlaSeconds: acknowledgementSlaSeconds(severity, config),
});
if (payload.status !== "resolved") {
scheduleEscalation(incident, messageId, renderedMessage, severity);
}
return json(res, 202, { accepted: true, traceId, messageId });
}
return json(res, 404, { error: "not_found" });
} catch (error) {
const status = error instanceof HttpError ? error.status : 502;
const safePath = new URL(req.url || "/", "http://relay.local").pathname;
if (safePath === "/alerts") metrics.failed += 1;
if (safePath === "/acknowledgements") metrics.acknowledgementFailures += 1;
log(status >= 500 ? "error" : "warn", "request_failed", {
method: req.method,
path: safePath,
status,
error: error.message,
});
return json(res, status, { accepted: false, error: error.message });
}
}
return { handler, metrics, prometheusMetrics };
}
export function startServer(config = loadConfig()) {
const relay = createRelay(config);
const server = createServer(relay.handler);
server.requestTimeout = 15000;
server.headersTimeout = 5000;
server.listen(config.port, "0.0.0.0", () => {
process.stdout.write(`${JSON.stringify({
ts: new Date().toISOString(),
level: "info",
event: "relay_started",
port: config.port,
escalationConfigured: Boolean(
config.backupReceiveId && config.backupReceiveId !== config.receiveId,
),
})}\n`);
});
return server;
}
export function isServerEntrypoint(argv1 = process.argv[1]) {
return Boolean(argv1 && basename(argv1) === "server.mjs");
}
if (isServerEntrypoint()) {
startServer();
}
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import {
acknowledgementSlaSeconds,
createRelay,
incidentMatchers,
incidentTraceId,
isServerEntrypoint,
signIncidentToken,
verifyIncidentToken,
} from "./server.mjs";
const NOW = Date.parse("2026-07-19T12:00:00Z");
const SECRET = "test-only-signing-secret";
test("ConfigMap symlink invocation is recognized as the relay entrypoint", () => {
assert.equal(isServerEntrypoint("/opt/alert-relay/server.mjs"), true);
assert.equal(isServerEntrypoint("/workspace/alert-relay/server.test.mjs"), false);
assert.equal(isServerEntrypoint(undefined), false);
});
function incident() {
return {
v: 1,
traceId: "trace-test-1",
issuedAt: Math.floor(NOW / 1000),
matchers: [{ name: "alertname", value: "SyntheticAlert", isRegex: false }],
fingerprints: ["fingerprint-1"],
};
}
function config() {
return {
port: 0,
appId: "test-app",
appSecret: SECRET,
receiveId: "primary-open-id",
backupReceiveId: "backup-open-id",
apiBase: "https://feishu.invalid",
alertmanagerBase: "http://alertmanager.invalid",
criticalSlaSeconds: 300,
warningSlaSeconds: 900,
acknowledgementSilenceSeconds: 7200,
primaryOwner: "primary",
backupOwner: "backup",
};
}
async function withServer(handler, callback) {
const server = createServer(handler);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
await callback(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
}
test("signed acknowledgement token round-trips and rejects tampering or expiry", () => {
const token = signIncidentToken(incident(), SECRET);
assert.deepEqual(verifyIncidentToken(token, SECRET, NOW), incident());
assert.throws(
() => verifyIncidentToken(token.slice(0, -1) + "x", SECRET, NOW),
/signature/,
);
assert.throws(
() => verifyIncidentToken(token, SECRET, NOW + 25 * 60 * 60 * 1000),
/expired/,
);
});
test("incident matchers are precise, non-regex and require alertname", () => {
assert.deepEqual(
incidentMatchers({
commonLabels: { alertname: "ApiDown", severity: "critical", service: "boot" },
alerts: [{ labels: { namespace: "ljwx-health" } }],
}),
[
{ name: "alertname", value: "ApiDown", isRegex: false },
{ name: "severity", value: "critical", isRegex: false },
{ name: "service", value: "boot", isRegex: false },
{ name: "namespace", value: "ljwx-health", isRegex: false },
],
);
assert.throws(() => incidentMatchers({ commonLabels: { severity: "warning" } }), /alertname/);
});
test("firing and resolved notifications keep one deterministic incident trace", () => {
const base = {
receiver: "warning-notifications",
groupKey: "{}:{alertname=\"SyntheticAlert\"}",
commonLabels: { alertname: "SyntheticAlert", severity: "warning" },
alerts: [
{
fingerprint: "fingerprint-1",
startsAt: "2026-07-19T12:00:00Z",
labels: { alertname: "SyntheticAlert" },
},
],
};
assert.equal(
incidentTraceId({ ...base, status: "firing" }),
incidentTraceId({ ...base, status: "resolved" }),
);
assert.match(incidentTraceId(base), /^incident-[a-f0-9]{32}$/);
});
test("critical and warning acknowledgement SLA stay independently configurable", () => {
const relayConfig = config();
assert.equal(acknowledgementSlaSeconds("critical", relayConfig), 300);
assert.equal(acknowledgementSlaSeconds("warning", relayConfig), 900);
assert.equal(acknowledgementSlaSeconds("unknown", relayConfig), 900);
});
test("explicit acknowledgement creates an Alertmanager silence and is observable", async () => {
const calls = [];
const fetchImpl = async (url, options = {}) => {
calls.push({ url, options });
if (url.endsWith("/api/v2/silences") && !options.method) {
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
}
if (url.endsWith("/api/v2/silences") && options.method === "POST") {
return new Response(JSON.stringify({ silenceID: "silence-test-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected fetch ${options.method || "GET"} ${url}`);
};
const relay = createRelay(config(), { fetchImpl, now: () => NOW, log: () => {} });
const token = signIncidentToken(incident(), SECRET);
await withServer(relay.handler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/acknowledgements`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, operator: "oncall.user" }),
});
assert.equal(response.status, 201);
const body = await response.json();
assert.equal(body.silenceId, "silence-test-1");
assert.equal(body.traceId, "trace-test-1");
});
assert.equal(calls.length, 2);
const silence = JSON.parse(calls[1].options.body);
assert.equal(silence.createdBy, "oncall.user");
assert.deepEqual(silence.matchers, incident().matchers);
assert.match(silence.comment, /trace_id=trace-test-1/);
assert.match(relay.prometheusMetrics(), /acknowledgements_total\{result="success"\} 1/);
});
test("acknowledgement rejects a tampered token before touching Alertmanager", async () => {
let fetchCalls = 0;
const relay = createRelay(config(), {
fetchImpl: async () => {
fetchCalls += 1;
throw new Error("must not be called");
},
now: () => NOW,
log: () => {},
});
const token = signIncidentToken(incident(), SECRET);
await withServer(relay.handler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/acknowledgements`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: token.slice(0, -1) + "x", operator: "oncall.user" }),
});
assert.equal(response.status, 403);
});
assert.equal(fetchCalls, 0);
});
test("read without explicit acknowledgement still escalates to the backup owner", async () => {
const calls = [];
const scheduled = [];
const fetchImpl = async (url, options = {}) => {
calls.push({ url, options });
if (url.endsWith("/auth/v3/tenant_access_token/internal")) {
return Response.json({ code: 0, tenant_access_token: "tenant-token", expire: 7200 });
}
if (url.includes("/im/v1/messages?receive_id_type=open_id")) {
const request = JSON.parse(options.body);
const suffix = request.receive_id === "backup-open-id" ? "backup" : "primary";
return Response.json({ code: 0, data: { message_id: `message-${suffix}` } });
}
if (url.endsWith("/api/v2/silences")) {
return Response.json([]);
}
if (url.includes("/read_users?user_id_type=open_id")) {
return Response.json({ code: 0, data: { items: [{ user_id: "primary-open-id" }] } });
}
throw new Error(`unexpected fetch ${options.method || "GET"} ${url}`);
};
const relay = createRelay(config(), {
fetchImpl,
now: () => NOW,
schedule: (callback, delay) => {
scheduled.push({ callback, delay });
return { unref() {} };
},
log: () => {},
});
await withServer(relay.handler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/alerts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
status: "firing",
receiver: "warning-notifications",
groupKey: "synthetic",
commonLabels: { alertname: "SyntheticAlert", severity: "critical" },
alerts: [{
fingerprint: "fingerprint-1",
startsAt: "2026-07-19T12:00:00Z",
labels: { alertname: "SyntheticAlert", severity: "critical" },
}],
}),
});
assert.equal(response.status, 202);
});
assert.equal(scheduled.length, 1);
assert.equal(scheduled[0].delay, 300000);
await scheduled[0].callback();
const messageRequests = calls
.filter((call) => call.url.includes("/im/v1/messages?receive_id_type=open_id"))
.map((call) => JSON.parse(call.options.body));
assert.deepEqual(messageRequests.map((request) => request.receive_id), [
"primary-open-id",
"backup-open-id",
]);
assert.match(JSON.parse(messageRequests[1].content).text, /原消息已读: 是/);
assert.match(relay.prometheusMetrics(), /read_checks_total\{result="read"\} 1/);
assert.match(relay.prometheusMetrics(), /escalations_total\{result="success"\} 1/);
});
test("an Alertmanager-backed explicit acknowledgement prevents escalation", async () => {
const scheduled = [];
let primaryMessageCalls = 0;
let backupMessageCalls = 0;
let traceId = "";
const fetchImpl = async (url, options = {}) => {
if (url.endsWith("/auth/v3/tenant_access_token/internal")) {
return Response.json({ code: 0, tenant_access_token: "tenant-token", expire: 7200 });
}
if (url.includes("/im/v1/messages?receive_id_type=open_id")) {
const request = JSON.parse(options.body);
if (request.receive_id === "backup-open-id") backupMessageCalls += 1;
else primaryMessageCalls += 1;
return Response.json({ code: 0, data: { message_id: "message-primary" } });
}
if (url.endsWith("/api/v2/silences")) {
return Response.json([{
id: "silence-ack-1",
startsAt: "2026-07-19T12:01:00Z",
comment: `ljwx-health acknowledgement; trace_id=${traceId}; fingerprints=fingerprint-1`,
status: { state: "active" },
}]);
}
throw new Error(`unexpected fetch ${options.method || "GET"} ${url}`);
};
const relay = createRelay(config(), {
fetchImpl,
now: () => NOW,
schedule: (callback) => {
scheduled.push(callback);
return { unref() {} };
},
log: () => {},
});
await withServer(relay.handler, async (baseUrl) => {
const response = await fetch(`${baseUrl}/alerts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
status: "firing",
receiver: "warning-notifications",
groupKey: "synthetic",
commonLabels: { alertname: "SyntheticAlert", severity: "warning" },
alerts: [{
fingerprint: "fingerprint-1",
startsAt: "2026-07-19T12:00:00Z",
labels: { alertname: "SyntheticAlert", severity: "warning" },
}],
}),
});
traceId = (await response.json()).traceId;
});
await scheduled[0]();
assert.equal(primaryMessageCalls, 1);
assert.equal(backupMessageCalls, 0);
assert.match(relay.prometheusMetrics(), /escalations_total\{result="success"\} 0/);
});
+9 -1
View File
@@ -3,8 +3,16 @@ kind: Kustomization
resources:
- configmap.yaml
- deployment.yaml
- alert-relay-configmap.yaml
- alert-relay.yaml
- pvc.yaml
- service.yaml
- skill-weather-configmap.yaml
configMapGenerator:
- name: ljwx-health-alert-relay
files:
- server.mjs=alert-relay/server.mjs
options:
labels:
app.kubernetes.io/name: ljwx-health-alert-relay
app.kubernetes.io/component: notifications
@@ -6,6 +6,9 @@ resources:
- statefulset-pgbackrest.yaml
- pgbackrest-rbac.yaml
- pgbackrest-jobs.yaml
- phase5-pre-adoption-backup-job.yaml
- phase5-post-adoption-backup-job.yaml
- phase5-schema-restore-drill-rbac.yaml
- prometheusrule.yaml
labels:
- includeSelectors: false
@@ -0,0 +1,129 @@
apiVersion: batch/v1
kind: Job
metadata:
name: postgres-pgbackrest-phase5-post-adoption-20260731-v1
annotations:
argocd.argoproj.io/sync-wave: "3"
labels:
app.kubernetes.io/name: postgres-pgbackrest-phase5-post-adoption
app.kubernetes.io/component: backup
operations.ljwx.io/change: ljwx-health-schema-adoption
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app.kubernetes.io/name: postgres-pgbackrest-phase5-post-adoption
app.kubernetes.io/component: backup
operations.ljwx.io/change: ljwx-health-schema-adoption
spec:
restartPolicy: Never
serviceAccountName: postgres-pgbackrest
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
initContainers:
- name: preflight
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- check
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
- name: backup
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- --type=diff
- backup
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
- name: verify
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- check
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
containers:
- name: verify-and-report
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- --output=json
- info
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
@@ -0,0 +1,129 @@
apiVersion: batch/v1
kind: Job
metadata:
name: postgres-pgbackrest-phase5-pre-adoption-20260731-v2
annotations:
argocd.argoproj.io/sync-wave: "3"
labels:
app.kubernetes.io/name: postgres-pgbackrest-phase5-pre-adoption
app.kubernetes.io/component: backup
operations.ljwx.io/change: ljwx-health-schema-adoption
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app.kubernetes.io/name: postgres-pgbackrest-phase5-pre-adoption
app.kubernetes.io/component: backup
operations.ljwx.io/change: ljwx-health-schema-adoption
spec:
restartPolicy: Never
serviceAccountName: postgres-pgbackrest
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
initContainers:
- name: preflight
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- check
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
- name: backup
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- --type=diff
- backup
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
- name: verify
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- check
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
containers:
- name: verify-and-report
image: docker.io/rancher/kubectl@sha256:6f157ad0ff43d31320ab7bf4015271a97466f13c49f03c5108b57b47a312d2c2
command: ["kubectl"]
args:
- exec
- postgres-infra-postgresql-0
- -c
- postgresql
- --
- pgbackrest
- --stanza=ljwx-health
- --output=json
- info
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
@@ -0,0 +1,32 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ljwx-health-schema-restore-drill-read
namespace: infra
annotations:
argocd.argoproj.io/sync-wave: "-2"
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["postgres-backup-credentials"]
verbs: ["get"]
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["postgres-infra-pgbackrest"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ljwx-health-schema-restore-drill-read
namespace: infra
annotations:
argocd.argoproj.io/sync-wave: "-2"
subjects:
- kind: ServiceAccount
name: ljwx-health-schema-restore-drill
namespace: ljwx-health
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ljwx-health-schema-restore-drill-read
+1
View File
@@ -7,4 +7,5 @@ resources:
- registry-pull-secret-sync-rbac.yaml
- registry-pull-secret-sync-cronjob.yaml
- website-prod-application.yaml
- ljwx-health-prod-application.yaml
- deploy-promoter-prod-cronjob.yaml
@@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ljwx-health-contabo-prod
namespace: argocd
annotations:
# The application is intentionally manual until the migration runbook's
# storage, Secret, restore, canary and failure-domain gates all pass.
ops.ljwx.io/cutover-gate: manual-preflight
spec:
project: default
source:
repoURL: https://github.com/lingjingwanxiang/ljwx-deploy.git
targetRevision: main
path: apps/ljwx-health/overlays/contabo-prod-staging
destination:
server: https://kubernetes.default.svc
namespace: ljwx-health
syncPolicy:
syncOptions:
- CreateNamespace=true
- PruneLast=true
@@ -25,12 +25,12 @@ spec:
env:
- name: SOURCE_NAMESPACE
value: default
- name: SOURCE_SECRET
value: harbor-registry
- name: SECRET_MAPPINGS
value: harbor-registry=harbor-registry,ghcr-pull=ghcr-pull,harbor-registry=regcred
- name: TARGET_NAMESPACE_SELECTOR
value: registry-sync.ljwx.io/enabled=true
- name: TARGET_SECRET_NAMES
value: harbor-registry,ghcr-pull,regcred
- name: DEFAULT_SERVICE_ACCOUNT_SECRET
value: harbor-registry
command:
- /bin/sh
- -ec
@@ -49,40 +49,58 @@ spec:
curl -fsSL -o "${kubectl_bin}" "https://dl.k8s.io/release/v1.33.5/bin/linux/${kubectl_arch}/kubectl"
chmod +x "${kubectl_bin}"
dockerconfigjson="$("${kubectl_bin}" -n "${SOURCE_NAMESPACE}" get secret "${SOURCE_SECRET}" -o jsonpath='{.data.\.dockerconfigjson}')"
if [ -z "${dockerconfigjson}" ]; then
echo "source secret is empty: ${SOURCE_NAMESPACE}/${SOURCE_SECRET}"
exit 1
fi
namespaces="$("${kubectl_bin}" get ns -l "${TARGET_NAMESPACE_SELECTOR}" -o jsonpath='{.items[*].metadata.name}')"
if [ -z "${namespaces}" ]; then
echo "no target namespaces matched: ${TARGET_NAMESPACE_SELECTOR}"
exit 0
fi
for ns in ${namespaces}; do
for secret_name in $(printf '%s' "${TARGET_SECRET_NAMES}" | tr ',' ' '); do
cat <<EOF | "${kubectl_bin}" apply -f -
for mapping in $(printf '%s' "${SECRET_MAPPINGS}" | tr ',' ' '); do
source_secret="${mapping%%=*}"
target_secret="${mapping#*=}"
if [ -z "${source_secret}" ] || [ -z "${target_secret}" ] \
|| [ "${source_secret}" = "${mapping}" ]; then
echo "invalid registry secret mapping: ${mapping}"
exit 1
fi
dockerconfigjson="$("${kubectl_bin}" -n "${SOURCE_NAMESPACE}" get secret "${source_secret}" -o jsonpath='{.data.\.dockerconfigjson}')"
if [ -z "${dockerconfigjson}" ]; then
echo "source secret is empty: ${SOURCE_NAMESPACE}/${source_secret}"
exit 1
fi
for ns in ${namespaces}; do
cat <<EOF | "${kubectl_bin}" apply \
--server-side \
--force-conflicts \
--field-manager=registry-pull-secret-syncer \
-f -
apiVersion: v1
kind: Secret
metadata:
name: ${secret_name}
name: ${target_secret}
namespace: ${ns}
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: ${dockerconfigjson}
EOF
"${kubectl_bin}" -n "${ns}" annotate secret \
"${target_secret}" \
kubectl.kubernetes.io/last-applied-configuration- \
>/dev/null 2>&1 || true
done
done
for ns in ${namespaces}; do
current="$("${kubectl_bin}" -n "${ns}" get sa default -o jsonpath='{.imagePullSecrets[*].name}' 2>/dev/null || true)"
if printf '%s\n' "${current}" | tr ' ' '\n' | grep -qx "${SOURCE_SECRET}"; then
if printf '%s\n' "${current}" | tr ' ' '\n' | grep -qx "${DEFAULT_SERVICE_ACCOUNT_SECRET}"; then
continue
fi
if [ -z "${current}" ]; then
"${kubectl_bin}" -n "${ns}" patch sa default --type=merge -p "{\"imagePullSecrets\":[{\"name\":\"${SOURCE_SECRET}\"}]}"
"${kubectl_bin}" -n "${ns}" patch sa default --type=merge -p "{\"imagePullSecrets\":[{\"name\":\"${DEFAULT_SERVICE_ACCOUNT_SECRET}\"}]}"
else
"${kubectl_bin}" -n "${ns}" patch sa default --type=json -p "[{\"op\":\"add\",\"path\":\"/imagePullSecrets/-\",\"value\":{\"name\":\"${SOURCE_SECRET}\"}}]"
"${kubectl_bin}" -n "${ns}" patch sa default --type=json -p "[{\"op\":\"add\",\"path\":\"/imagePullSecrets/-\",\"value\":{\"name\":\"${DEFAULT_SERVICE_ACCOUNT_SECRET}\"}}]"
fi
done
@@ -0,0 +1,80 @@
# LJWX Health schema adoption preflight — 2026-07-31
Evidence captured read-only at `2026-07-31T00:01:52Z`
(`2026-07-31T08:01:52+08:00`). No marker, backup, schema, Secret, workload, or
GitOps state was written during capture.
## GitOps and runtime baseline
- Argo CD application: `ljwx-health-k3s-eu`
- Git revision: `c8b3d519ed88c85c23f7e572a62bfcf50800bb9f`
- Argo state: `Synced`, `Healthy`, last operation `Succeeded`
- Runtime images:
- `ljwx-boot@sha256:5efccf398c82a0de35bb3f55e7adb4f8de12aefb884231097f1a5cc03a5400c8`
- `ljwx-admin@sha256:7f9087a6a7b7bfaa3940d28df688c53591067a2b1252a013ff7a7814430121cd`
- `ljwx-bigscreen@sha256:e2dda38cff822350d365c5f39b6e44e8673e93706cb2d0c8c36e63e06f6e139c`
- `health-writer@sha256:e33fce63a84d89f9a4cd4b11da1257604a8a7a76a5cfd7dde7e78149402641bc`
- `health-recon@sha256:496047abbcb6823e7b69eb5655f16c83be0afbf22aa95e61be46162ebf15d3fd`
- Ready replicas: boot `2`, admin `2`, bigscreen `1`, writer `1`, recon `1`
## Source database identity and catalog baseline
- Database: `ljwx_health`
- PostgreSQL: `18.3` (`server_version_num=180003`)
- PostgreSQL system identifier: `7612871620259237941`
- Timeline: `1`
- Read-only checkpoint LSN observed during capture: `22/B2007078`
- Extensions: `pg_trgm=1.6`, `plpgsql=1.0`, `postgis=3.6.2`
- Public tables: `155` (ordinary and partitioned)
- Public columns: `2947`
- Public sequences: `77`
- RLS-enabled tables: `61`
- Force-RLS tables: `63`
- Canonical schema history entries: `0`
- Canonical production inventory fingerprint:
`sha256:300e44ca6bc4be80f8794e63fea20509cfcbde9f32ce21a8aa160d2bf071f2a2`
- Adoption-plan expected fingerprint: exact match
## Qualified pre-adoption backup
- Engine/stanza: `pgBackRest 2.58.0`, `ljwx-health`
- Repository: S3-compatible bucket `backups`, path
`/pgbackrest/ljwx-health`, status `ok`, cipher `none`
- Database identity in repository: PostgreSQL `18`, system identifier
`7612871620259237941`
- Full base label: `20260725-181501F`
- Latest differential label:
`20260725-181501F_20260730-191501D`
- Backup Job: `postgres-pgbackrest-diff-29757315`, succeeded
- Backup start/stop: `2026-07-30T19:15:01Z` /
`2026-07-30T19:15:06Z`
- Start/stop LSN: `22/78000028` / `22/78009380`
- Start/stop WAL: `000000010000002200000078`
- Logical database size: `3,801,549,504` bytes
- Repository size: `1,256,122,758` bytes
- Differential data: `369,246,470` bytes; repository delta
`51,000,613` bytes; `8,677` files
- Backup manifest path:
`backup/ljwx-health/20260725-181501F_20260730-191501D/backup.manifest`
- Backup manifest size: `2,028,568` bytes
- Backup manifest SHA-256:
`4985224eecf1ad62eedecca9bf85ac9a2100f31c728b6f335b0d5a089d2f297a`
- Archive range available during capture:
`0000000100000010000000E3` through
`0000000100000022000000B1`
- Backup and restore locks: not held
Isolated restore is pinned with:
```text
pgbackrest --stanza=ljwx-health \
--set=20260725-181501F_20260730-191501D \
--pg1-path=/restore \
--type=immediate \
--target-action=promote \
--archive-mode=off restore
```
`/restore` must be an isolated `emptyDir`; it must not mount or reuse the
production PostgreSQL volume. Completion of that restore and its schema
fingerprint are separate post-promotion acceptance evidence.
@@ -0,0 +1,217 @@
# LJWX Health Contabo staged migration and failure-domain acceptance
Issues: `lingjingwanxiang/ljwx-health#390`, `#395`
This runbook moves LJWX Health from the current single-node production cluster
to the existing three-node Contabo k3s cluster without changing the release or
schema sources of truth. The destination Argo CD Application is deliberately
manual and initially renders the `contabo-prod-staging` overlay.
## Non-negotiable safety boundaries
- Do not delete or rewrite unrelated Contabo workloads, Docker volumes, Ollama
models, database files, or object-store data to recover disk space. Expand
the affected disk, or obtain explicit asset-owner approval for a separately
backed-up archive operation.
- Do not sync the destination Application until every preflight gate below is
recorded. Creating the Application alone does not authorize a sync.
- Do not run two production cloudflared connector sets for the same tunnel
during canary. The staging overlay removes all cloudflared resources.
- Do not create credentials in Git or place decoded Secret data in evidence.
Provision destination Secrets through the protected operator path and record
only names and resource versions.
- Do not treat hostnames, public IPs, or node count as proof of independent
physical failure domains. Infrastructure must record the provider placement
evidence used to assign each `topology.kubernetes.io/zone` label.
- Do not promote if a backup, restore, schema fingerprint, immutable image,
canary, node-loss, or rollback check fails.
## Source-of-truth contract
One protected `ljwx-health` commit produces the signed Release Manifest,
digest-pinned images, signed schema bundle and expected production schema
fingerprint. One protected `ljwx-deploy` commit selects those immutable
artifacts. Migration changes only the observed destination cluster.
For each attempt, record all of the following in one evidence directory:
- Health source SHA and Release Manifest digest;
- Deploy source SHA and Argo CD observed revision;
- expected and observed image digests for Boot, Admin, Bigscreen,
schema-runner, writer and recon;
- schema source SHA, schema manifest digest, expected production fingerprint
and the restored database's observed fingerprint;
- source backup label, start/stop LSN, stop time, manifest checksum and object
repository identity;
- destination node names, declared zones, Ready/schedulable state and provider
placement evidence;
- canary/final HTTP checks, workload placement, drill timeline, RTO/RPO and
rollback outcome.
Any mismatch is a stop condition. A matching branch name, image tag or table
count is not equality evidence.
## Stage 0: freeze identities and prepare evidence
Use explicit contexts and verify them before every write:
```bash
export LJWX_ACTIVE_CONTEXT='<current-production-context>'
export LJWX_CONTABO_CONTEXT='<contabo-context>'
export LJWX_EVIDENCE_DIR="test-results/contabo-migration/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "${LJWX_EVIDENCE_DIR}"
kubectl --context "${LJWX_ACTIVE_CONTEXT}" cluster-info
kubectl --context "${LJWX_CONTABO_CONTEXT}" cluster-info
git rev-parse HEAD | tee "${LJWX_EVIDENCE_DIR}/deploy-source-sha.txt"
kubectl kustomize apps/ljwx-health/overlays/contabo-prod-staging \
>"${LJWX_EVIDENCE_DIR}/staging-render.yaml"
```
Before a source backup, stop write-producing jobs and establish the declared
write-freeze window. Record the authenticated API smoke immediately before the
freeze. Take a pgBackRest full or differential backup, require a successful
`check`, and copy the immutable backup set to a destination that does not share
the source database node or storage failure domain.
## Stage 1: repair and qualify the destination storage plane
The 2026-08-02 preflight found `vmi2822146` at 100% root usage with
`DiskPressure`; the dominant consumer was an unrelated Ollama container's
writable model layer. MinIO, PostgreSQL and MySQL local-path volumes were pinned
to that node, leaving replacement pods Pending. Repair must preserve those
assets.
Require all of these before migration:
```bash
kubectl --context "${LJWX_CONTABO_CONTEXT}" get nodes
kubectl --context "${LJWX_CONTABO_CONTEXT}" describe nodes \
| grep -E '^(Name:| DiskPressure|Taints:)'
kubectl --context "${LJWX_CONTABO_CONTEXT}" get pods -A \
--field-selector=status.phase!=Running,status.phase!=Succeeded
kubectl --context "${LJWX_CONTABO_CONTEXT}" get pv,pvc -A -o wide
```
Acceptance:
- every node has at least 20% free root/image filesystem capacity;
- no node has `DiskPressure` or an unplanned storage taint;
- each moved volume has a checksum-verified copy and a documented rollback
location before the original is changed;
- PostgreSQL, MinIO and any required shared dependency have Running endpoints
and pass application-level read/write checks;
- no data-bearing volume is counted as an independent backup of itself.
## Stage 2: declare verified failure domains and provision prerequisites
After Infrastructure records provider placement evidence, apply stable zone
labels. The example values are placeholders, not proof:
```bash
kubectl --context "${LJWX_CONTABO_CONTEXT}" label node <node-a> \
topology.kubernetes.io/zone=<verified-zone-a> --overwrite
kubectl --context "${LJWX_CONTABO_CONTEXT}" label node <node-b> \
topology.kubernetes.io/zone=<verified-zone-b> --overwrite
kubectl --context "${LJWX_CONTABO_CONTEXT}" label node <node-c> \
topology.kubernetes.io/zone=<verified-zone-c> --overwrite
```
Provision the destination registry pull Secret, runtime Secret, schema
migrator Secret and cloudflared credential Secret through the protected secret
path. Validate Traefik, cert-manager, the `dnspod-letsencrypt` ClusterIssuer,
Prometheus Operator CRDs and required dependency Services before syncing.
Staging intentionally disables Kafka/ClickHouse shadow writers; they must be
restored or explicitly accepted before final production cutover.
## Stage 3: restore and prove schema identity
Restore the frozen backup into PostgreSQL 18 on the destination. Run the signed
schema bundle verify path against the restored database. If upgrade is needed,
run only the digest-pinned bundle selected by the same Release Manifest, then
verify again.
Acceptance requires exact equality between the Release Manifest's expected
production fingerprint and the observed normalized fingerprint. Also compare
database system identifier policy, extensions, public table/column/sequence
counts, RLS policies, role/privilege boundary and representative tenant data.
Preserve the restore logs and machine-readable schema evidence.
## Stage 4: create and manually sync the canary
After this GitOps change is merged to protected `main`, create only the manual
Application and inspect the diff:
```bash
kubectl --context "${LJWX_CONTABO_CONTEXT}" apply \
-f cluster-prod/ljwx-health-prod-application.yaml
argocd app diff ljwx-health-contabo-prod
argocd app sync ljwx-health-contabo-prod --prune
argocd app wait ljwx-health-contabo-prod --sync --health --operation
```
The only public hosts allowed during this stage are:
- `health-admin-contabo-canary.lingjingwanxiang.cn`
- `health-dashboard-contabo-canary.lingjingwanxiang.cn`
Require authenticated API/UI smoke, zero unexpected restarts, both Boot and
Admin replicas Ready, and actual placement across at least two verified zones.
Run the Health repository's exact-main `scripts/ops_readiness.py --live` against
the destination and preserve the result.
## Stage 5: node-loss, entrypoint and restore drills
Drain one workload node at a time. Never drain the next node until the previous
node is uncordoned, Ready and all critical replicas have reconverged:
```bash
kubectl --context "${LJWX_CONTABO_CONTEXT}" drain <node> \
--ignore-daemonsets --delete-emptydir-data --timeout=10m
# run authenticated canary/API, alert delivery and placement checks
kubectl --context "${LJWX_CONTABO_CONTEXT}" uncordon <node>
```
Repeat with an actual node stop/restart if Infrastructure can safely control
the VM. During each loss, require the canary UI and core API to remain within
the declared RTO, record all failed requests, and verify connector/alert paths.
Independently restore the destination backup copy into an isolated volume that
does not mount production data. Require pgBackRest checksums, the same schema
fingerprint, tenant-data checks and application smoke. Record RPO and RTO. The
restore target and evidence must remain usable when the database node is
unavailable.
## Stage 6: final cutover
Final cutover is a separate reviewed GitOps change that switches the
Application source path from `contabo-prod-staging` to `contabo-prod`. Before
merge, restore the Kafka/ClickHouse shadow path or record the approved
production-equivalent design; the staging disablement is not an implicit
waiver.
At the write-freeze boundary, replay/restore any final WAL, rerun schema and
data equality checks, stop the old production connector set, then allow the
destination cloudflared connector. Verify both production domains, authenticated
API/UI smoke, alerts, metrics, traces, image digests and workload placement.
Keep the old production cluster read-only and rollback-capable until the agreed
soak period completes.
## Rollback
- Before final WAL replay: stop/delete only the destination Application and
retain destination data for diagnosis. The current cluster remains primary.
- After final replay but before traffic cutover: keep writes frozen, compare
source/destination LSNs and return to the source only if no destination write
was accepted.
- After destination writes begin: do not perform an unplanned reverse switch.
Freeze writes, reconcile data, make an incident decision and record the data
authority before changing traffic.
- For a manifest regression, revert the final GitOps path change to staging or
the recorded N-1 deploy SHA. Do not reverse a successful schema migration.
Close `#390` only after the machine-readable readiness result has no
failure-domain FAIL, both node-loss drills and the isolated restore pass, and
all evidence identifies immutable sources. Close `#395` only after `#392` also
has distinct real-human primary/backup acknowledgement and escalation drill
evidence.
@@ -0,0 +1,130 @@
schemaVersion: 2
kind: LJWXHealthLegacySchemaCompatibilityMap
capturedFromGitOpsRevision: 6cee4f8d413ad075ca19da968676cef82e0e6dce
canonical:
repository: lingjingwanxiang/ljwx-health
schemaRelease: 3.1.72
manifest: ljwx-boot/database/schema/schema-manifest.yaml
adoptionPlan: ljwx-boot/database/schema/adoption/production-k3s-eu-pg18.json
policy:
postgresqlCopies: removed-from-gitops-after-qualified-acceptance
forwardCopies: immutable-history-only-use-canonical-bundle
rollbackCopies: audit-map-only-never-execute
semanticDifferences: canonical-bundle-wins
retirementEvidence:
preCompatibilityBaselineGitOpsRevision: 0f7f812907f27a4d12e8d2d34c331f757dc52d33
preCompatibilityBaselineSmokeRun: 30660871019
productionReleaseTag: ljwx-health-release-ddddd7c6f37ba5dc8d55968a600c0c8556029cc9-run-30650721146-attempt-1
productionFingerprint: sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8
qualifiedBackupLabel: 20260725-181501F_20260731-181020D
restoreDrillGitOpsRevision: 7345fa7342990fdbcdd588278e6760c4360eab85
restoreDrillResult: passed
restoreRpoSeconds: 4291
restoreRtoSeconds: 67
preAdoptionBackupResult: rejected-by-signed-fingerprint-allowlist
legacyHookOrphanCleanup:
discoveryGitOpsRevision: 2b2cd694284d5d6b81ef45603529386508b36198
ignoredOrdinaryAdoptionRevision: 188cd35e4146def983d01b29c7327275a32e8d15
deletionGitOpsRevision: 96db8706b5616dea0a851b4648bb1f147a61168e
deletionPullRequest: 357
deletedConfigMaps: 15
deletedBootstrapRbacObjects: 3
result: passed
applicationCompatibility:
result: passed
n:
releaseTag: ljwx-health-release-ee2e655bd137cf83e5f43c638009ed1a3a0fc839-run-30660382221-attempt-1
promotionRun: 30662199540
gitOpsRevision: 1c86d14a90f397292265d7a095cb6722a86cf764
smokeRun: 30662908812
bootDigest: sha256:c9bcea82ef9da03b3315a5550cb4cbfa456f71d6267e492f304aa085cb7f50ba
nMinusOne:
releaseTag: ljwx-health-release-ddddd7c6f37ba5dc8d55968a600c0c8556029cc9-run-30650721146-attempt-1
promotionRun: 30662946594
gitOpsRevision: 08936fd1d77654dd40c0ecde0e8748f5a73c0a2c
smokeRun: 30663462575
bootDigest: sha256:1cbcbe840fd6a69646cb11b625e8d4c7c4659d3ae63165516e1692bafe52380f
restoredN:
promotionRun: 30663515760
gitOpsRevision: 6cee4f8d413ad075ca19da968676cef82e0e6dce
smokeRun: 30664125859
rejectedOlderCandidate:
sourceSha: cfe3a8f9c77351c9fed1753aca60dfde42e9ab4c
smokeRun: 30659653798
result: rejected-after-authenticated-user-info-500
mappings:
- legacy: apps/ljwx-health/base/sql/epic-360/00-health-olap-recon.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.30__health_olap_recon.sql
- legacy: apps/ljwx-health/base/sql/epic-360/10-rls-elevation-lease.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.40__rls_elevation_lease.sql
- legacy: apps/ljwx-health/base/sql/epic-360/15-rls-policies.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/R__rls_policies.sql
- legacy: apps/ljwx-health/base/sql/epic-360/20-health-upload-identity.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.39__health_upload_identity.sql
- legacy: apps/ljwx-health/base/sql/epic-360/30-health-archive-manifest.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.41__health_archive_manifest.sql
- legacy: apps/ljwx-health/base/sql/epic-360/40-health-olap-recon-tenant.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.42__health_olap_recon_tenant.sql
- legacy: apps/ljwx-health/base/sql/epic-360/50-tenant-root-closure.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.43__tenant_root_closure.sql
- legacy: apps/ljwx-health/base/sql/epic-360/60-user-transfer-status-permissions.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.44__user_transfer_status_permissions.sql
- legacy: apps/ljwx-health/base/sql/epic-360/70-health-realtime-score-state.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.45__health_realtime_score_state.sql
- legacy: apps/ljwx-health/base/sql/epic-360/80-message-effective-recipients.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.46__message_effective_recipients.sql
- legacy: apps/ljwx-health/base/sql/epic-360/90-health-archive-permissions.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.47__health_archive_permissions.sql
- legacy: apps/ljwx-health/base/sql/mobile-scope/97-mobile-subject-scope-roles.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.49__mobile_subject_scope_roles.sql
- legacy: apps/ljwx-health/base/sql/mobile-scope/98-mobile-health-aggregate-scope.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.50__mobile_health_aggregate_scope.sql
- legacy: apps/ljwx-health/base/sql/mobile-scope/99-zzzzz-user-feedback-subject-scope.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.58__user_feedback_subject_scope.sql
- legacy: apps/ljwx-health/base/sql/mobile-scope/99-zzzzzz-device-self-scope.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.59__mobile_device_self_scope.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-authorization-relation-tenant-keys.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.51__authorization_relation_tenant_keys.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-z-tenant-authorization-version-facts.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.53__tenant_authorization_version_facts.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zz-tenant-role-template-binding-convergence.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.55__tenant_role_template_binding_convergence.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzz-navigation-projection-consistency.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.69__schema_source_convergence.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzz-health-task-subject-scope.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.60__health_task_subject_scope.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzz-user-org-primary-membership.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.61__user_org_primary_membership.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzz-org-manager-assignment.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.62__org_manager_assignment.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzzz-user-position-lifecycle.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.63__user_position_lifecycle.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzzzz-temporal-device-binding.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.64__temporal_device_binding.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzzzzz-resource-provenance-envelope.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.65__resource_provenance_envelope.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzzzzzz-heartbeat-current-unique.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.66__ia15_heartbeat_current_unique.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzzzzzzz-tenant-blueprint-foundation.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.67__tenant_blueprint_foundation.sql
- legacy: apps/ljwx-health/base/sql/authorization/99-zzzzzzzzzzzz-tenant-blueprint-operations.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.68__tenant_blueprint_operations.sql
- legacy: apps/ljwx-health/base/sql/alert-ingress/99-zzzz-external-alert-purpose-field-policy.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.57__external_alert_purpose_field_policy.sql
- legacy: apps/ljwx-health/base/sql/health-policy/95-health-collection-policy-v2.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.69__schema_source_convergence.sql
- legacy: apps/ljwx-health/base/sql/health-policy/96-health-data-platform-default-read.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.69__schema_source_convergence.sql
- legacy: apps/ljwx-health/base/sql/authorization/rollback/99-authorization-relation-tenant-keys.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.51__authorization_relation_tenant_keys.sql
disposition: audit-only-forward-fix-or-backup-restore
- legacy: apps/ljwx-health/base/sql/authorization/rollback/99-z-tenant-authorization-version-facts.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.53__tenant_authorization_version_facts.sql
disposition: audit-only-forward-fix-or-backup-restore
- legacy: apps/ljwx-health/base/sql/authorization/rollback/99-zz-tenant-role-template-binding-convergence.sql
canonical: ljwx-boot/ljwx-boot-admin/src/main/resources/db/migration-pg/V3.1.55__tenant_role_template_binding_convergence.sql
disposition: audit-only-forward-fix-or-backup-restore
nonPostgres:
- legacy: apps/ljwx-health/base/sql/health-shadow/clickhouse-health-raw.sql
owner: health-shadow-clickhouse
disposition: retained-outside-postgresql-schema-bundle
@@ -0,0 +1,119 @@
# LJWX Health signed schema bundle rollout
This runbook governs the one-time production adoption and the steady-state
schema path introduced by `lingjingwanxiang/ljwx-health#713`.
## Safety boundary
- Only a complete signed Release Manifest may activate the schema Job.
- The GitOps promotion projection pins the Release Manifest OCI digest, schema
OCI digest, schema-runner image digest, source SHA, manifest digest, expected
production fingerprint, signer identity, and Release binding digests.
- The foundation manifests are dormant. The promotion script activates them
only after receipt validation.
- The first run may use the existing PostgreSQL administrator Secret solely for
ownership adoption. Upgrade and verify run as `ljwx_migrator`.
- After the first successful production adoption, remove the temporary
credential-bootstrap RBAC, the administrator Secret reference, and the
adoption command. Steady-state releases run only as `ljwx_migrator`.
- A failed signature, provenance, binding, bundle checksum, manifest checksum,
migration, or fingerprint check fails the Argo CD PreSync hook and blocks the
application rollout.
## Pre-adoption gate
1. Confirm Argo CD is `Synced` and `Healthy`; record its exact Git revision.
2. Run the canonical production inventory in read-only mode and require the
adoption-plan fingerprint.
3. Require PostgreSQL 18 and all required extensions from the production track.
4. Require a successful pgBackRest full/differential backup whose stop time is
before the adoption starts. Record the stanza, system identifier, timeline,
label, start/stop LSN, repository path, manifest checksum, sizes, and restore
command.
5. Record public table/column/sequence counts, RLS counts, and runtime image
digests.
6. Verify the signed Release and schema OCI subjects before the GitOps write.
7. Do not promote if any recorded identity differs from the current live
read-only inventory.
## Production order
1. Merge the dormant GitOps foundation.
2. Publish one complete Release from an exact protected `ljwx-health` main SHA.
3. Approve Production Promotion for that Release Manifest.
4. The promotion script writes only digest-bound GitOps facts and activates the
new PreSync path.
5. Argo CD first removes the legacy init/repair hooks from the rendered overlay,
then runs `ljwx-health-schema-bundle`.
6. The Job verifies Release and schema signatures, SPDX and SLSA attestations,
the exact schema-to-Release binding, bundle checksum, source SHA, schema
manifest checksum, and expected production fingerprint.
7. The Job adopts existing objects exactly once as administrator, archives
Flyway history, creates the restricted owner/migrator boundary, and then
upgrades and verifies as `ljwx_migrator`.
8. Only a successful PreSync allows application rollout.
9. Archive Argo operation state, Job logs, adoption/upgrade/verify evidence,
observed images, readiness, smoke, and manual acceptance separately.
## Failure handling
- Before adoption commits: fix the Release/GitOps contract and repromote; the
database is unchanged.
- During adoption or a single-transaction migration: PostgreSQL transaction
rollback applies. Preserve the failed Job and logs.
- After schema succeeds but before application readiness: roll the application
back only to the declared N-1 digest. Do not reverse the database migration.
- For data corruption or an incompatible schema failure: stop rollout and
restore the recorded pgBackRest set into an isolated environment first.
Production restore requires a separate incident decision and approval.
- Never use the historical ConfigMap SQL copies as a repair source.
## Restore drills and retirement
Evaluate both restore candidates in isolated drills:
1. restore the recorded pre-adoption backup and run adoption plus upgrade;
2. take the normal post-adoption backup, restore it, and run verify without
adoption.
Each drill must use an `emptyDir`, isolated credentials and network path, must
not mount the production data volume, and must record RPO/RTO, table/sequence
counts, RLS/privileges/extensions, normalized fingerprint, application smoke,
cleanup, and repeatability. A pre-adoption backup that does not match the
signed adoption allowlist is not a recovery source: the drill must fail closed,
record the observed fingerprint and classify that backup as ineligible. Do not
expand or rewrite the allowlist after the fact to force an old backup through.
Only after production adoption, a qualified post-adoption restore passes, the
pre-adoption candidate has either passed the signed adoption path or been
explicitly disqualified, and the N/N-1 application rollback window passes may
the cleanup change remove:
- the SQL ConfigMap generators and historical SQL files;
- `schema-revision.yaml`;
- `job-init-db.yaml` and `job-flyway-repair.yaml`;
- the temporary migrator-secret bootstrap RBAC and administrator Secret
reference.
Keep the compatibility map, Flyway archive, Release/GitOps receipts, backup
evidence, restore reports, and observed-state evidence.
## 2026-08-01 production acceptance
- Production adoption and verify converged on normalized fingerprint
`sha256:565a8f1edf4b997ecfd66e6c3f06b33ebead4842a51d8fbb92d5b1f5f75d9ee8`.
- The pre-adoption backup candidate failed closed because its observed
fingerprint was outside the signed allowlist; it is recorded as ineligible
and was not forced through adoption.
- The qualified post-adoption backup
`20260725-181501F_20260731-181020D` restored in isolation and passed catalog,
data, application smoke, and cleanup checks with RPO 4,291 seconds and RTO 67
seconds.
- The repaired application window passed N, N-1, then restored N on GitOps
revisions `1c86d14a90f397292265d7a095cb6722a86cf764`,
`08936fd1d77654dd40c0ecde0e8748f5a73c0a2c`, and
`6cee4f8d413ad075ca19da968676cef82e0e6dce`; authenticated smoke runs
`30662908812`, `30663462575`, and `30664125859` all succeeded.
- These gates authorize the controlled retirement change tracked by
`lingjingwanxiang/ljwx-deploy#355`; they do not authorize deletion of
immutable Releases, the Flyway audit archive, or pgBackRest backups.
+5 -2
View File
@@ -58,10 +58,13 @@ kubectl -n ljwx-website-prod get sa default -o jsonpath='{.imagePullSecrets[*].n
Registry pull secret sync (generic):
- source secret: `default/harbor-registry`
- source mappings: `default/harbor-registry -> harbor-registry`,
`default/ghcr-pull -> ghcr-pull`, and
`default/harbor-registry -> regcred`
- target namespaces: label `registry-sync.ljwx.io/enabled=true`
- synced names: `harbor-registry`, `ghcr-pull`, `regcred`
- patch target namespace `default` ServiceAccount with `harbor-registry`
- never alias Harbor credentials under the `ghcr-pull` name; private GHCR
artifact verification requires the dedicated `default/ghcr-pull` source
## Grafana dashboard + TLS (GitOps)
@@ -0,0 +1,37 @@
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0000", "chunk_index": 0, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 17, "source_start_line": 1, "text_hash": "848cf657795ea0d3104d15772be3818a2ef2f39f892152e85c78abd84e0f5cb6", "token_count": 131, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0001", "chunk_index": 1, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 36, "source_start_line": 19, "text_hash": "00a8e2447a0e6c7575d39c75d3a678b76e4c6c5103380bfc7522fba4c6f7db4f", "token_count": 274, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0002", "chunk_index": 2, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 49, "source_start_line": 38, "text_hash": "5a7c3dcce8725c63f5a4bcf09db7ea272d3c7acd6269b063fcd02707dd53ad05", "token_count": 311, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0003", "chunk_index": 3, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 51, "source_start_line": 51, "text_hash": "de15131e2e4c48517dcbedd4bdf67374a19af9664e145de5abc7eb6e624850d5", "token_count": 4, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0004", "chunk_index": 4, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 66, "source_start_line": 53, "text_hash": "6804ab48cbad4bc2f5df7b1ef8f82c24cb39230f91af873e9ee8d8b5520e00ca", "token_count": 597, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0005", "chunk_index": 5, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 93, "source_start_line": 68, "text_hash": "86bb0d253b8363508c4e6e3b9b760f4a7e54ddaae08e96a86ad9a5c39b997b14", "token_count": 303, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0006", "chunk_index": 6, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 136, "source_start_line": 95, "text_hash": "4d53ba8d24ef9e3c8f454086bc414fdc00afef388168ed7a0bc13c8e8ab7bbb4", "token_count": 261, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0007", "chunk_index": 7, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 171, "source_start_line": 138, "text_hash": "7efd0f34dd78e8d98df188e8d0f78f0739eee92a73a5a14622312504ac3fcf2a", "token_count": 268, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0008", "chunk_index": 8, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 200, "source_start_line": 173, "text_hash": "3b17df18fdb6134a59154d6c95fabe093871e3ade095392676768d72ea41d903", "token_count": 240, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0009", "chunk_index": 9, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 241, "source_start_line": 202, "text_hash": "a6d33fa10ad46ff9eee4efc4ab92bfd5319740dd3d2dc0b3767e8ba3ae5c5896", "token_count": 299, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0010", "chunk_index": 10, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 288, "source_start_line": 243, "text_hash": "8a45e77919a10bc133ecc9c9bbc0fc160d1018d0e37ab43e84389a3996148b16", "token_count": 283, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0011", "chunk_index": 11, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 320, "source_start_line": 290, "text_hash": "77466f73567cb0b35614f806429a1e1da01cdf18e82635308c44ecc6fb2d2739", "token_count": 240, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0012", "chunk_index": 12, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 347, "source_start_line": 322, "text_hash": "093d608d9bcadb7eb7d53cbae6436b328c2564e49be1c794f292bfe02a9650b1", "token_count": 252, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0013", "chunk_index": 13, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 378, "source_start_line": 349, "text_hash": "e491e43cd1b8a58e58eb67a50e554edf7b96b7aade05c072cfcb286f6bc91df4", "token_count": 266, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0014", "chunk_index": 14, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 411, "source_start_line": 380, "text_hash": "2de9818f8db4e9e5c4fba0e5adc18103e303e96d16019ed6af031285cc91b3f5", "token_count": 297, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0015", "chunk_index": 15, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 434, "source_start_line": 413, "text_hash": "ef8864dd35b8254e92e2e1a4d7902d73d70a5fc228c4c6dd93c5f0a30612d086", "token_count": 279, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0016", "chunk_index": 16, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 462, "source_start_line": 436, "text_hash": "aebcdaccb40e7fe532fcf854fbda9b34868a78bbdeb0696f7688b6aec8387391", "token_count": 174, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0017", "chunk_index": 17, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 483, "source_start_line": 464, "text_hash": "ccdc79b09612e809b6c19ca84e92e7a4333204af8d97db918ecce5f3052d404d", "token_count": 159, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0018", "chunk_index": 18, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 506, "source_start_line": 485, "text_hash": "2c58cf322b6231c62f4ee8f907cd6c262338345d1c2876c4a5f67ca0feceea7b", "token_count": 294, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0019", "chunk_index": 19, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 538, "source_start_line": 508, "text_hash": "e5e8b8e99e43db580814bfb46029f5b06407c98fef5a793185062d3a5442a60a", "token_count": 302, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0020", "chunk_index": 20, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 575, "source_start_line": 540, "text_hash": "b3c1d250d631e2b28521df2d18361684c9fd42ca1cf8fe6cd565cc53859af31a", "token_count": 300, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0021", "chunk_index": 21, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 601, "source_start_line": 577, "text_hash": "4ae27bb639ccdd1de74c2bba6cc7b6b0550ca809be46b6b09ca0316e6546c933", "token_count": 269, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0022", "chunk_index": 22, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 641, "source_start_line": 603, "text_hash": "cb559e40c3f71914d94223e9ef304796e83da03283d46d753db3389768145c8a", "token_count": 303, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0023", "chunk_index": 23, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 682, "source_start_line": 643, "text_hash": "71f8b628b1e74cd2231bd1684e504c318338c2e0f70ba46d868f8392de676c33", "token_count": 213, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0024", "chunk_index": 24, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 699, "source_start_line": 684, "text_hash": "7f5562961273a2696d56c24e77e3d233758c7cb32af676332a053e9cea4faf0e", "token_count": 282, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0025", "chunk_index": 25, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 719, "source_start_line": 701, "text_hash": "ba8a12d9b89e0290530e11024fae15f01f2a324e5d45ded6d3cafab088f75bef", "token_count": 136, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0026", "chunk_index": 26, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 731, "source_start_line": 721, "text_hash": "0c868bf5bc0462de652ac2ad10b9153bee2fd972acc19697240acc72c98f1bf2", "token_count": 381, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0027", "chunk_index": 27, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 747, "source_start_line": 733, "text_hash": "fc3bd4c1fe0224346027fce4fbe01cf1913316077e335de1dda15809605c2422", "token_count": 218, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0028", "chunk_index": 28, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 757, "source_start_line": 749, "text_hash": "c57f811598606c93d8f507a8f6478d5bf7a3cabc5359a3d0d5c5a633018d2943", "token_count": 220, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0029", "chunk_index": 29, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 801, "source_start_line": 759, "text_hash": "4f407409d00a0c8b3a855aa646a038ecc527a188fd40c664ffd37d7ce1cc4ffb", "token_count": 262, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0030", "chunk_index": 30, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 859, "source_start_line": 803, "text_hash": "88dc8f369429cce81aa117365ea3d321cf8c63c65588819200aba1c9d6954c60", "token_count": 288, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0031", "chunk_index": 31, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 886, "source_start_line": 861, "text_hash": "a509a5cf7b3198dc3a2ea241f98c1d672960a372c912807c12550bd51cc47d67", "token_count": 119, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0032", "chunk_index": 32, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 904, "source_start_line": 888, "text_hash": "b984b9da413bf43e367ca026d9fef63a948bf3b9d0879264e44bea6696d2cd97", "token_count": 261, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0033", "chunk_index": 33, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 908, "source_start_line": 906, "text_hash": "05da055d08de3500cc429d3254a9103562039ff290efb0b0aba61dc1945aee70", "token_count": 62, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0034", "chunk_index": 34, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 917, "source_start_line": 910, "text_hash": "d6ad23dec889cc6f4081111b2a7bbffe946b4a6961afe2d5bd5eff72e2aec307", "token_count": 645, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0035", "chunk_index": 35, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 921, "source_start_line": 919, "text_hash": "5b66d7e4d1af519d0ced19d73d353bc5ee611142b3ca94fa0cefd8e447ddf6b5", "token_count": 65, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0036", "chunk_index": 36, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 937, "source_start_line": 923, "text_hash": "1482899c09be5b10fc0a678d7f6f809dd92b69465018355cab587ee67fdbb438", "token_count": 464, "visibility": "internal"}
@@ -0,0 +1 @@
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "8afab9e82cca677ba55d07a014541049524b333a35f906a94d264e8c5b9f5586", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@035afcfc16dad7c896d536c6edcf68117a6767a3:docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "多租户身份、权限、健康资源与多终端总体架构", "version": "035afcfc16dad7c896d536c6edcf68117a6767a3", "visibility": "internal"}
@@ -0,0 +1,937 @@
# 多租户身份、权限、健康资源与多终端总体架构
**状态**: Accepted
**版本**: v1.12
**创建日期**: 2026-07-10
**评审日期**: 2026-07-10
**适用范围**: `ljwx-boot` / `ljwx-admin` / `ljwx-bigscreen` / `ljwx-phone` / `ljwx-watch` 及后续手环、无人机、机器人、第三方服务
**配套文档**: [业务数据链全景](./business-data-chains.md) / [设备抽象层](./device-abstraction-layer.md)
**实施跟踪**: [GitHub Epic #383](https://github.com/lingjingwanxiang/ljwx-health/issues/383)
---
## 1. 结论与关键决策
本设计补齐 `business-data-chains.md` 的横向架构:业务链文档回答“数据怎样流动”,本文回答“谁能以什么身份、在什么租户和组织范围内,对哪些资源做什么,以及不同终端怎样安全、高效地消费同一份业务能力”。
核心决策如下:
1. **角色、岗位、菜单三者解耦**:角色授予功能权限与数据范围;岗位表达人员业务属性和健康风险权重;菜单只做终端导航投影,不能成为后端授权依据。
2. **统一授权上下文**:每次请求只解析一次 `AccessContext`,统一给 Controller、Service、Mapper、缓存键和审计日志使用;客户端传入的 `customerId/userId/orgId` 只能是查询意图,不能成为可信身份。
3. **租户隔离、功能权限、数据权限、资源关系分层判定**:RLS 只解决租户边界,Sa-Token 权限只解决“能否执行动作”,组织/用户/设备/收件人关系由资源范围策略单独校验。
4. **组织权限按绑定点生效**:`sys_user_org_role` 中的角色只在对应组织及配置的子树内生效;登录时可以合并“功能权限集合”,但每次资源访问必须再次按目标组织收窄。
5. **岗位默认不授予权限**:岗位用于风险等级、健康评分、排班/工种等业务规则。确有“岗位自动带角色”的需求时,使用显式映射和审批,不在代码里把岗位名硬编码为权限。
6. **健康事实保存事件发生时归属**:健康数据、告警、消息投递保存 `customer_id + subject_user_id + org_id_at_event + device_id/device_sn + occurred_at`。人员调动不改写历史事实。
7. **授权看当前关系,报表看历史事实**:能否查看某个用户默认按当前组织关系判定;历史报表默认按事件发生时组织聚合。两个口径不得隐式混用。
8. **设备是独立主体,不是用户的另一种登录名**:手表/手环使用设备凭据,机器人/无人机使用设备或服务主体;绑定关系决定它代表哪个用户、组织或任务,`deviceSn` 本身不是密钥。
9. **领域 API 唯一,终端 BFF 负责裁剪**:Admin、Bigscreen、Phone、Watch 不复制健康/告警/消息规则,只消费统一领域服务;BFF 负责聚合、隐私、带宽、离线和交互差异。
10. **PostgreSQL 是事实源,Redis/大屏快照是可重建投影**:高频组织/用户查询通过索引、闭包表、聚合表和可重建缓存加速,不建立第二套不可对账的事实源。
---
## 2. 代码与本地数据库核查结论
本节记录 2026-07-10 的仓库和 `ljwx-local-postgres17/ljwx_health` 事实。目标架构不能把“已有表”误写成“已经完整接线”。
### 2.1 已有基础
| 能力 | 当前实现 | 结论 |
|---|---|---|
| 租户上下文 | `GlobalRequestInterceptor` 从 Sa-Token Session 初始化 `UserContext/TenantContext`,支持 CURRENT/TARGET/ALL | 可保留,但要让所有业务查询使用 effective tenant |
| 数据库隔离 | 主要用户、组织、健康、设备、告警、消息事实表已启用 RLS;应用层另有 MyBatis 租户拦截器 | 有双层基础,但覆盖并不完整,且本地连接用户 `ljwx` 是 `rolsuper=true, rolbypassrls=true`,本地普通请求不能证明 RLS 生效 |
| 组织模型 | `sys_org_units + sys_org_closure`,已有祖先/后代索引 | 适合组织树与管辖范围查询,无需另造树模型 |
| 组织管理范围 | `PrincipalBasedOrgManagerResolver` 已按 `sys_user_org.principal + sys_org_closure` 解析管理范围;`sys_org_manager_cache` 表、索引和 tenant isolation policy 已存在 | 解析器和可重建缓存可复用,但独立的 manager 事实关系尚未建立 |
| 基础 RBAC | `sys_role/sys_permission/sys_role_permission`,Sa-Token 从登录角色加载 permission resource | 功能权限主干可复用 |
| 管理边界 | `AuthorizationChain` 已包含 `AdminBoundaryGuard/OrgScopeGuard/RoleDelegateGuard` | 目前主要接在用户和组织写操作,尚未成为所有资源访问的统一入口 |
| 健康事实维度 | `t_user_health_data` 已有 `customer_id/org_id/user_id/device_sn/ts`,并有 tenant-user-time、tenant-org-time 索引 | 已具备高效查询的物理基础,需要明确 `org_id` 是事件快照 |
| 业务资源关联 | 设备、绑定、心跳、告警、消息及明细大多已有 customer/user/org/device 字段 | 字段存在但关系权威性和时间语义尚未统一 |
| 终端入口 | Admin workbench、Bigscreen V6 BFF、Mobile API、Watch API 均已存在 | 可按现有入口渐进收敛,不需要重建全部 API |
| 隐私呈现 | Bigscreen 已有 `private/pseudonymized/aggregate-only` | 可提升为跨终端统一字段策略 |
### 2.2 必须先解决的断点
| 断点 | 代码证据 | 风险 |
|---|---|---|
| 组织角色没有进入登录授权 | `SysRoleMapper.queryRoleListWithUserId()` 只 join `sys_user_role`;Session、菜单、权限都使用该结果;`sys_user_org_role` 仅有独立 CRUD | 组织角色“配得上、用不上”,或被迫重复成全局角色 |
| DataScope 没有形成强制查询入口 | `ScopeQueryFactory` 除自身外无业务调用;`DataScopeInterceptor.CUSTOM` 返回 `null`,而 `null` 同时表示 TENANT_ALL | 同租户跨部门、跨用户读取;CUSTOM 实际放大成全租户 |
| 菜单覆盖仅完成首段接线 | `AuthenticationFacadeImpl` 已把 `sys_menu_tenant_override` 的隐藏、别名、排序应用于当前登录人的 resolved assignment 菜单集合;menu-permission 一致性校验和版本缓存尚未完成 | 路由呈现已受租户覆盖约束,但缓存失效前仍不能把配置变更视为即时全实例生效 |
| 菜单与权限关系不完整 | 菜单来自 `sys_role_menu`,按钮/API 权限来自 `sys_role_permission`,两条路径独立 | 可能出现“有菜单无 API 权限”或“有 API 权限无入口”漂移 |
| 关系表租户隔离不一致 | `sys_user_role/sys_role_menu/sys_role_permission` 无 `customer_id`;本地 `sys_role_menu/sys_role_permission` 已 ENABLE + FORCE RLS 但没有 policy;`sys_user_role` 为 `relrowsecurity=false, relforcerowsecurity=true`,属于 FORCE 已设但尚未 ENABLE 的半配置态 | 受限数据库角色下可能拒绝全部;超级用户下又会掩盖问题;实施时不能把 `sys_user_role` 误判为“只差 policy” |
| 部分健康关系表未进 RLS | `t_device_heartbeat` 未启用 RLS,`R__rls_policies.sql` 也未包含 heartbeat 和 `t_device_message_department` | 设备状态或消息受众关系可能绕过统一租户边界 |
| `principal` 语义冲突 | 业务代码把 `sys_user_org.principal` 当组织负责人;数据库唯一索引却约束 `(user_id, customer_id) WHERE principal='1'`,名为“单一主部门” | 一个字段同时承担“主组织”和“负责人”,无法正确表达多组织管理 |
| 终端仍可传入越权维度 | Mobile `/health/data` 已改为 AccessContext 的 operator/effective tenant,并仅兼容与当前用户事实一致的 query hint;Bigscreen 的 realtime/snapshot/rebuildCache/examSummary/interventionSummary/SSE/alert action/detail/assignees 已按各自 permission 编译 subject scope,仅 TENANT_ALL 可调用租户聚合和管理入口且 query tenant 只能匹配 effective tenant;Viewer alert detail 已将 token org scope 与告警持久化 tenant/org 求交,Viewer 管理面也按 `bigscreen:viewer:manage` 要求 tenant-wide assignment scope;Health V2 score/profile 与 Alert V2 list/detail/handle/assign/status 已按 permission-specific scope 校验目标用户、当前组织、租户与持久化告警关系 | Mobile 与 Bigscreen 租户聚合、SSE、Admin/Viewer 告警入口、Health V2 score/profile、Alert V2 首段已 fail closed;其余 Health/Message/Device 入口与 Bigscreen viewer 聚合组织过滤仍需逐项校验同租户组织、用户与资源关系 |
| Watch 以 SN 代替认证 | `/watch/*` 为 `@SaIgnore`,仅凭 `X-Device-SN` 查绑定用户后注入上下文 | 知道或伪造 SN 即可冒充设备上传/收消息 |
| 当前组织与历史组织混用 | 一部分查询直接 join `sys_user.org_id`,另一部分读取事实表 `org_id` | 人员调动后历史报表漂移,不同页面统计口径不一致 |
| 领域模型有重复与映射漂移 | system/health 下并存多套同表 Entity;`TDeviceMessage` 对 `priority_level` 有重复属性映射 | 后续权限和资源字段扩展容易只改中一套模型 |
| 负向测试缺口 | 现有测试覆盖租户切换、事件租户隔离和大屏隐私,但未系统覆盖“同租户跨组织/跨用户/伪造设备 SN” | 安全回归难以及时发现 |
### 2.3 当前索引结论
已经适合保留的索引:
- `t_user_health_data(customer_id, user_id, ts DESC)`
- `t_user_health_data(customer_id, org_id, ts DESC)`
- `sys_org_closure(ancestor_id, customer_id)`、`(descendant_id, customer_id)` 和唯一闭包关系
- `sys_user_org(customer_id, user_id, org_id, is_deleted)`
- `sys_org_units(customer_id, parent_id)`、`(customer_id, status, is_deleted)`
明显缺失或不足的索引:
- `sys_role_menu(role_id, menu_id)`、`sys_role_permission(role_id, permission_id)`、`sys_user_role(customer_id, user_id, role_id)` 的租户化唯一索引
- `sys_user_position(customer_id, user_id, position_id)` 唯一索引;当前名为 customer 的索引实际只有 `user_id`
- `t_device_info(customer_id, user_id)`、`t_device_info(customer_id, org_id, online_status, last_report_time DESC)`
- `t_device_heartbeat(customer_id, user_id, last_heartbeat_at DESC)` 以及当前设备唯一约束
- `t_alert_info(customer_id, user_id, occur_at DESC)`、`t_alert_info(customer_id, org_id, alert_status, occur_at DESC)`
- `t_device_message(customer_id, user_id, sent_time DESC)`
- `t_device_message_detail(customer_id, user_id, response_status, sent_time DESC)`
- `t_device_message_department(customer_id, department_id, message_id)`
索引必须由真实查询计划决定。上述列表是首批候选,不应不经 `EXPLAIN (ANALYZE, BUFFERS)` 就全部上线。
---
## 3. 目标架构
```mermaid
flowchart TB
subgraph Surface[终端与交互面]
ADMIN[Admin]
BIG[Bigscreen]
PHONE[Phone]
WEAR[Watch / Band]
EDGE[Drone / Robot / Gateway]
end
subgraph BFF[Surface BFF / API Adapter]
NAV[Navigation + Capability API]
ABFF[Admin BFF]
BBFF[Bigscreen BFF]
MBFF[Mobile BFF]
DBFF[Device Ingress / Command API]
end
subgraph Access[身份与策略平面]
AUTHN[Human / Device / Service Authentication]
CTX[AccessContext Resolver]
PDP[Policy Decision: tenant + permission + data scope + relation + privacy]
AUDIT[Decision Audit]
end
subgraph Domain[领域服务]
USER[User / Organization / Position]
DEVICE[Device Registry / Binding]
HEALTH[Health Query / Ingest]
ALERT[Alert Center]
MESSAGE[Message Center]
end
subgraph Data[事实与投影]
PG[(PostgreSQL / TimescaleDB facts)]
OUTBOX[(Outbox)]
SPRING[Required Spring Event publisher]
KAFKA[Kafka publisher / transport]
CH[(ClickHouse projection)]
READ[(Org/User read models)]
REDIS[(Redis rebuildable cache)]
end
Surface --> BFF
BFF --> AUTHN --> CTX --> PDP
PDP --> Domain
PDP --> AUDIT
Domain --> PG
Domain --> OUTBOX --> SPRING --> READ --> REDIS
OUTBOX -. shadow / promoted .-> KAFKA --> CH
KAFKA -. durable projection after promotion .-> READ
PG -. rebuild / reconcile .-> READ
PG -. PG↔CH reconcile .-> CH
BFF --> READ
BFF --> REDIS
```
### 3.1 分层职责
| 层 | 只负责 | 不负责 |
|---|---|---|
| 认证 AuthN | 证明人、设备、服务是谁 | 决定能看哪些业务数据 |
| 租户隔离 | 限制 effective customer | 代替组织/用户数据权限 |
| 功能权限 RBAC | 判断 action/resource permission | 从菜单可见性推导权限 |
| 数据范围 | 计算可访问组织和用户集合 | 跨租户放行 |
| 资源关系 | 校验本人、绑定设备、告警受理人、消息收件人等关系 | 替代 RBAC |
| 隐私策略 | 字段脱敏、聚合阈值、用途限制 | 改变事实数据 |
| BFF | 聚合、裁剪、协议适配、缓存策略 | 重写领域规则 |
| Read Model | 加速确定口径的查询 | 成为不可重建事实源 |
### 3.2 事件源与投影边界
`t_health_event_outbox` 是领域事务提交后的**唯一事件源**。现有 `HealthEventOutboxWorker` 读取同一条 outbox 记录,再交给 publisher adapter:
- `SpringEventHealthEventPublisher` 是当前 required 的进程内运行链;
- `KafkaHealthEventPublisher` 是当前 non-required 的 shadow publish,复用同一事件标识与 payload;
- Kafka 是传输通道,ClickHouse、组织/用户读模型和 Redis 都只是可重建投影;PostgreSQL 事实仍是对账基准。
禁止领域服务在“写 PG 后”再独立直发另一条 Kafka 事件,也禁止 Spring Event 与 Kafka 各自定义无法关联的事件 ID。进程内低延迟投影可以消费 required Spring Event;跨进程投影在 Kafka 完成 shadow 对账、重放和延迟门禁后再切为 durable consumer。每个消费者必须以 outbox event id 幂等,保存消费位点/版本,并提供 PG rebuild、diff 和 lag 指标。现有 Bigscreen Redis 热缓存从 PG 或 canonical read model 重建,不把未经晋级的 Kafka shadow 数据当成第二事实源。
---
## 4. 身份、组织、岗位、角色和菜单模型
### 4.1 主体模型
统一使用 `Principal` 表达调用主体:
| principal_type | 示例 | 认证方式 | 默认数据边界 |
|---|---|---|---|
| `HUMAN` | 平台管理员、租户管理员、部门负责人、员工 | Sa-Token/OIDC,后续可接 MFA | 角色 + 组织范围 + 资源关系 |
| `DEVICE` | Watch、手环、机器人、无人机 | 设备注册凭据、签名 nonce、短期 token 或 mTLS | 设备自身 + 当前有效绑定/任务 |
| `SERVICE` | 定时任务、第三方告警源、内部 worker | workload identity/API key/mTLS | 明确 service scope,不模拟普通用户 |
| `VIEWER` | 无人值守大屏 | 短期 viewer token | 固定租户/组织 + 固定 surface + 隐私模式 |
设备和服务主体不能伪造 `UserContext` 后获得完整用户权限。需要代表用户执行时,必须保留:
```text
actor_principal = device/service
subject_user_id = 被代表的用户
delegation_source = binding/task/grant
```
### 4.2 组织与人员关系
推荐语义:
```text
sys_user [已有] 人的租户内账号
sys_org_units [已有] 组织树
sys_user_org [已有,待修正语义] 人员组织任职关系
- is_primary 当前主组织,只能一个
- valid_from / valid_to 关系有效期
sys_org_manager [新建] 组织管理责任,不再复用 is_primary/principal
- customer_id, org_id, user_id
- manager_type, valid_from, valid_to
sys_org_closure [已有] 组织祖先/后代投影
sys_org_manager_cache [已有] 管理范围缓存,可重建
```
当前 `PrincipalBasedOrgManagerResolver` 已能基于 `sys_user_org.principal` 和 closure 计算管理范围,并读取既有 `sys_org_manager_cache`。迁移时保留该解析器作为兼容适配层:先引入新的 `sys_org_manager` 事实表,再让 resolver/cache 改从新事实重建,最后停止复用 `principal`。
迁移期间可以保留 `sys_user.org_id/org_name` 作为当前主组织读优化字段,但它们是 `sys_user_org.is_primary=true` 的投影,不是关系事实源。
### 4.3 岗位语义
岗位回答“这个人在业务上做什么”,角色回答“这个账号在系统里能做什么”。
岗位可承载:
- 工种/职级/作业场景;
- `risk_level/weight` 等健康风险参数;
- 默认采集策略、体检计划、健康阈值模板;
- 排班、在岗、暴露因素等后续业务属性。
岗位不能直接承载:
- 菜单列表;
- API permission 字符串;
- 跨组织数据范围;
- 平台或租户管理员级别。
如果一个用户在不同组织有不同岗位,目标关系应包含 `org_id` 和有效期。`sys_user_position` 已有 `customer_id` 物理列,首期应确保它始终正确写入,再补 `org_id/valid_from/valid_to` 和 tenant-aware 唯一索引;后续再评估是否收敛成统一 assignment 表。
### 4.4 角色与组织绑定
角色分两种绑定:
```text
sys_user_role
-> 租户级或 SELF 角色
-> 在整个租户或本人范围生效
sys_user_org_role
-> 组织绑定角色
-> 只在绑定 org_id 或其子树内生效
```
登录授权快照不能只是一组 roleId,应保存带作用域的 assignment:
```json
{
"authzVersion": 42,
"roleAssignments": [
{"assignmentId": 1001, "roleId": 10, "roleDataScope": "SELF", "boundary": "TENANT"},
{"assignmentId": 1002, "roleId": 21, "roleDataScope": "DEPT_AND_CHILD", "boundary": "ORG_SUBTREE", "anchorOrgId": 3001}
],
"permissions": ["health:record:read", "alert:handle"],
"scopeDigest": "sha256:..."
}
```
`permissions` 可以为前端动作提示做并集;后端处理具体 `action + targetOrgId/subjectUserId` 时必须用原始 assignment 再判定,不能只看并集,也不能把 assignment A 的 permission 与 assignment B 的范围拼成一次授权。
### 4.5 菜单与能力投影
菜单生成公式:
```text
EffectiveNavigation
= PlatformBaselineMenu
∩ TenantEntitlement
∩ RoleMenuGrant
∩ PermissionAvailability
∩ SurfaceCapability
⊖ TenantOverride.hidden
+ TenantOverride.alias/sort
```
后端授权只检查 permission,不检查菜单。建议提供:
- `GET /api/auth/capabilities?surface=admin|bigscreen|phone|watch`
- `GET /api/auth/navigation?surface=admin`
- 返回 `authzVersion/navigationVersion/tenantConfigVersion`,支持客户端和 Redis 精确失效。
Phone、Watch 不必复刻 Admin 菜单树;它们消费 capability 列表和固定的原生导航。未来若需要租户可配置终端模块,再引入 `sys_surface_feature`,不要把 Admin Vue route 直接下发给设备。
### 4.6 多租户资源生命周期
沿用现有“三层资源模型”,但为模板升级增加可治理的版本语义:
| 资源 | 所有权与策略 | 更新方式 |
|---|---|---|
| 菜单、permission | 平台基线,租户只读;受 license/entitlement 约束 | 平台发布;租户仅通过 override 做隐藏、别名、排序 |
| 角色模板 | 平台 blueprint,创建租户时复制 | 租户副本可编辑;保留 `source_template_id/template_version` |
| 岗位模板 | 平台 blueprint,租户复制后可按组织调整风险参数 | 同角色模板,禁止平台更新静默覆盖租户修改 |
| 租户角色/岗位 | 租户自治 | 只在本租户可见,受管理员边界和委派池限制 |
| 组织、用户、assignment | 租户事实 | 不从模板继承 |
模板升级流程必须是 `diff -> preview -> apply -> audit -> rollback`:
```text
TenantBlueprint(version)
-> compare source_template_id + last_synced_version
-> classify unchanged / tenant-modified / removed / new
-> auto-apply only unchanged items
-> require tenant decision for conflicts
```
新增 permission 可以通过 entitlement 灰度开放;删除或改名必须先检查角色、菜单、终端 capability 和 API 注解引用。模板同步不能直接复用“创建租户时无条件复制”的逻辑。
---
## 5. 统一授权模型
### 5.1 AccessContext
```text
AccessContext
actorPrincipalId/type
operatorUserId # HUMAN 或被授权的操作用户
loginCustomerId
tenantScope # CURRENT / TARGET / ALL
effectiveCustomerId
surface # admin / bigscreen / phone / watch / edge
grantAssignments[] # 权限与范围不可拆开的原始授权单元
assignmentId / roleId
permissionRefs[]
roleDataScope # SELF / DEPT_ONLY / DEPT_AND_CHILD / TENANT_ALL / CUSTOM
boundary # TENANT / ORG_SUBTREE
anchorOrgId / customScopeId
validFrom / validTo
permissionsHint[] # 仅供能力提示的并集,不是后端授权依据
scopeDigest # assignments 的稳定摘要,用于缓存键与审计
deviceId / bindingId
privacyMode
authzVersion # platform/tenant/user version tuple
traceId
```
所有字段由服务端 Session、设备凭据、绑定关系和租户配置解析。Header `X-Client-Platform/X-Device-Vendor/X-Source-Adapter` 只参与遥测和能力协商。`AccessContext` 不保存一个扁平的最终 `dataScope`:多组织 assignment 的范围只能结合当前 action 和目标资源求值。
### 5.2 判定公式
```text
ALLOW = authenticated(actor)
AND tenantVisible(effectiveTenant, resource.customerId)
AND existsGrantAssignment(actor, action, resource)
AND relationAllows(actor, resource, action)
AND surfaceAllows(surface, action)
AND privacyAllows(purpose, fields)
AND notBreakAdminBoundary(actor, target)
```
建议新增统一入口:
```java
accessPolicy.authorize(Action.READ_HEALTH_RECORD, ResourceRef.health(recordId));
scopedQuery.healthRecords(query, accessContext);
```
Controller 的 `@SaCheckPermission` 继续作为第一道功能权限 PEP;Service 层的 `AccessPolicy` 是不可绕过的资源级 PEP;数据库 RLS 是最后的租户边界。账号禁用、租户冻结、管理员边界、隐私禁止和显式 deny 属于 hard deny,优先于任何 grant assignment。
### 5.3 数据范围编译
| DataScope | 查询谓词 |
|---|---|
| SELF | `subject_user_id = actorUserId` |
| DEPT_ONLY | `org_id IN (角色绑定组织)` |
| DEPT_AND_CHILD | `EXISTS sys_org_closure WHERE ancestor_id IN (managedRoots) AND descendant_id = resource.org_id` |
| TENANT_ALL | 仅 `customer_id = effectiveCustomerId` |
| CUSTOM | join `sys_data_scope_member`;没有成员时返回空集,绝不能退化为 TENANT_ALL |
每次授权按目标资源编译,而不是先把多个 assignment 压成“最宽枚举”:
1. 只选择当前有效且自身授予目标 permission 的 assignments;
2. 将角色数据范围与 assignment 边界取交集:`effectiveScope = roleDataScope ∩ boundary`;除 `SELF/TENANT_ALL` 外必须有明确 anchor,租户级部门角色按当前有效组织展开成独立 assignments,不能隐式共用一组 managed roots;
3. 每个 assignment 独立判断目标 `orgId/subjectUserId`,任一完整 assignment 覆盖目标即可放行,即同一 action 的 grant 范围取并集;
4. `sys_user_org_role` 的最大边界始终是绑定组织/子树,即使其角色声明 `TENANT_ALL` 也不能越出该边界;
5. `CUSTOM` 成员也必须与 assignment 边界取交集;不能把一个 assignment 的 permission 与另一个 assignment 的 scope 交叉拼接。
因此,一个用户在租户级只有 `SELF`,同时在组织 A 持有 `DEPT_AND_CHILD + health:record:read` 时:本人资源由前一个 assignment 放行;组织 A 子树资源由后一个 assignment 放行;组织 B 仍拒绝。`permissionsHint` 可以显示该用户具备 read 动作,但不能证明任意目标资源可读。
小组织可以把 org IDs 缓存在授权快照中;大组织直接用 closure `EXISTS/JOIN`,避免生成数千项 `IN (...)`。
建议增加:
```text
sys_data_scope
id, customer_id, name, status, version
sys_data_scope_member
scope_id, member_type(ORG/USER), member_id, include_children
```
### 5.4 当前关系与历史归属
同一查询必须显式选择两种口径之一:
| 口径 | 用途 | 组织来源 |
|---|---|---|
| `CURRENT_SUBJECT_SCOPE` | 判断管理员现在能否查看某用户 | 当前有效 `sys_user_org` + closure |
| `EVENT_ORG_SCOPE` | 历史趋势、部门绩效、事件审计 | 事实表 `org_id_at_event` |
默认规则:先用当前关系完成授权,再按请求的报表口径聚合。用户离开组织后,原部门管理员是否还能看历史个人明细属于隐私策略,不通过改写历史数据实现。
### 5.5 缓存与失效
版本事实由 PostgreSQL 持有,不由某个应用实例或 Redis 自行发号。目标表可以采用:
```text
sys_platform_authz_version(id=1, authz_version, updated_at)
sys_tenant_authz_version(customer_id, authz_version, navigation_version, blueprint_version, updated_at)
sys_user_authz_version(customer_id, user_id, authz_version, updated_at)
```
对用户生效的 `authzVersion` 是 `(platformVersion, tenantVersion, userVersion)` 元组或其稳定摘要;navigation 再加入 `navigationVersion`。授权缓存键必须包含:
```text
authz:{customerId}:{userId}:{platformVersion}:{tenantVersion}:{userVersion}
navigation:{customerId}:{userId}:{surface}:{platformVersion}:{tenantVersion}:{userVersion}:{navigationVersion}
scope:{customerId}:{userId}:{scopeDigest}:{tenantVersion}:{userVersion}
```
只有 `AuthorizationVersionService` 可以递增版本;它与授权事实变更在同一数据库事务内执行原子 `UPDATE ... version = version + 1 RETURNING version`,并写入 authorization outbox。递增归属如下:
- 用户调动、用户角色/组织角色、设备绑定和账号状态等单用户变化递增 user version;
- 角色定义、permission、菜单、租户 override、组织结构等影响一批用户的变化递增 tenant version,菜单类同时递增 navigation version;
- 平台 permission/menu 基线和全局策略变化递增 platform version;
- blueprint 发布递增 blueprint version,只有实际应用到租户时才同步递增该 tenant 的授权/navigation 版本。
提交后事件负责删除 Redis/L1 快照并广播新版本;Redis 只镜像最新版本,miss 时回源 PostgreSQL,多实例不得依赖本机内存计数器。订阅丢失由短 TTL + DB 回源兜底。账号禁用、角色移除、设备凭据撤销等高风险收权必须在提交后同步更新共享版本指针、撤销 Session/token;高风险请求在版本广播不可用或版本不确定时直接回源 PostgreSQL 校验,不能等待缓存 TTL,也不能继续使用旧快照。版本递增失败则授权事实事务整体回滚。
---
## 6. 健康业务资源归属模型
### 6.1 通用资源信封
所有健康领域事实和事件采用统一逻辑字段;物理列可阶段性沿用现名:
```text
resource_id
customer_id
subject_type # USER / DEVICE / ORG / MISSION / EXTERNAL
subject_user_id # 当前多数表物理列为 user_id
org_id_at_event # 当前多数表物理列为 org_id
position_id_at_event # 需要按事件时岗位解释风险时保存
device_id / device_sn
source_resource_type/id
occurred_at
ingested_at
actor_principal_id/type
trace_id
schema_version
```
### 6.2 资源关系
```mermaid
erDiagram
TENANT ||--o{ ORG : owns
TENANT ||--o{ USER : owns
ORG ||--o{ USER_ORG : contains
USER ||--o{ USER_ORG : assigned
USER ||--o{ USER_POSITION : works_as
ROLE ||--o{ USER_ROLE_ASSIGNMENT : grants
USER ||--o{ USER_ROLE_ASSIGNMENT : receives
ORG ||--o{ USER_ROLE_ASSIGNMENT : scopes
DEVICE ||--o{ DEVICE_BINDING : has
USER ||--o{ DEVICE_BINDING : bound_to
DEVICE_BINDING ||--o{ HEALTH_OBSERVATION : produces_for
HEALTH_OBSERVATION ||--o{ ALERT : triggers
ALERT ||--o{ MESSAGE : publishes
MESSAGE ||--o{ MESSAGE_DELIVERY : fans_out
USER ||--o{ MESSAGE_DELIVERY : receives
```
### 6.3 各资源的权威关系
| 资源 | 权威归属 | 当前可复用字段 | 必须收敛的规则 |
|---|---|---|---|
| 设备注册 | tenant + device | `t_device_info.customer_id/serial_number` | `(customer_id, serial_number)` 唯一;user/org 只是当前绑定投影 |
| 设备绑定 | tenant + device + user + org + validity | `t_device_user` | `BOUND` 的时间区间唯一;解绑保留历史,不硬删除 |
| 心跳 | tenant + device + current binding | `t_device_heartbeat.customer_id/user_id/device_sn` | 服务端从凭据和绑定注入,不能信任 body userId |
| 健康数据 | tenant + subject user + event org + device + time | `t_user_health_data` 已具备主要字段 | `org_id` 明确为事件快照;绑定无效时拒绝或进入隔离队列 |
| 告警 | tenant + subject + event org + source resource | `t_alert_info.user_id/org_id/device_sn/health_id/source_*` | source 必须可追溯;查询按 subject 与 handler 两套关系区分 |
| 消息主记录 | tenant + publisher + audience snapshot | `t_device_message.audience/target_scope/source_event_id` | 群组消息不能把主表单个 user/org 当唯一受众事实 |
| 消息投递 | tenant + recipient user + org-at-publish + channel/client | `t_device_message_detail` | 已读/确认只能由收件人或授权操作员变更 |
### 6.4 设备绑定的时间语义
推荐的绑定约束:
```text
同一 tenant + device 在任一时刻最多一个 ACTIVE/BOUND 用户
同一用户可以同时绑定多个设备
每个用户可以有一个 primary device,但 primary 不是唯一设备
解绑只关闭 valid_to,不删除历史
重新绑定必须生成新 binding_id
健康上传按 occurred_at 匹配当时有效绑定,至少按接收时绑定做严格校验
```
`sys_user.device_sn`、`t_device_info.user_id/org_id` 可保留为当前绑定投影,禁止继续作为唯一事实源。
### 6.5 设备初始信任与凭据生命周期
“绑定审批通过”与“设备已经证明自己是谁”是两件事。现有 `t_device_bind_approval` 和 `DeviceBindApprovalServiceImpl` 作为 provisioning 的业务授权锚点,但审批记录本身不是设备凭据:
1. 设备先使用出厂预置公钥/证书、一次性 QR 激活码或线下安全下发的 bootstrap secret 证明其库存身份;只保存 public key、credential id 或 secret hash,不保存明文长期密钥;
2. HUMAN 用户发起绑定请求,审批流确认 `customer + device + user + org`,审批通过后才允许 provisioning;
3. 设备完成 possession challenge 后领取短期 token,或注册设备生成的公钥并取得客户端证书;bootstrap 凭据随即标记 consumed,不能重放;
4. token/证书中的 tenant、device、binding、capability 来自服务端事实;解绑、遗失、租户冻结或审批撤销时独立轮换/撤销;
5. 仅凭 `deviceSn` 的旧设备进入有期限、有审计的 allowlist 兼容通道,不能自动完成首次注册。
短期 token、请求签名、nonce/replay 与 `/watch/**` 的 `disabled -> monitor -> enforce`
迁移契约见
[`device-request-authentication.md`](../06-development/backend/device/device-request-authentication.md)。
该实现只闭合 bootstrap proof 之后的请求认证链;生产 KMS/Vault 凭据供应和
Phone/Watch 安全存储仍是切换 `enforce` 的前置条件。
### 6.6 非用户设备
机器人、无人机等资源不应强制绑定 `user_id`:
- 设备可直接归属 tenant/org;
- 一次任务通过 `mission_id/operator_user_id` 关联人员;
- 设备遥测进入通用 telemetry/observation,再按能力映射到健康、环境或安全事件;
- 下行指令要求独立 `device:command:*` 权限、双向认证、幂等 commandId 和审计回执;
- 不把机器人/无人机的控制能力放进普通 Phone token。
---
## 7. 高效组织与用户查询架构
### 7.1 查询入口
新增统一 `SubjectScopeQueryService`,禁止各 Controller 自行拼 `customerId/orgId/userId`:
```text
resolve AccessContext
-> validate requested user/org belongs to effective tenant
-> intersect requested scope with allowed scope
-> produce AuthorizedSubjectScope
-> domain query uses the authorized scope
```
```text
AuthorizedSubjectScope
customerId
mode = SELF | USER_SET | ORG_SUBTREE | TENANT
userIds? # 小集合才物化
rootOrgIds?
includeChildren
grouping = EVENT_ORG | CURRENT_ORG
```
### 7.2 查询模式
| 场景 | 推荐 SQL 形状 | 说明 |
|---|---|---|
| 用户个人时序 | `customer_id + user_id + ts range` | 命中现有 user-time 索引 |
| 单组织历史趋势 | `customer_id + org_id_at_event + ts range` | 命中现有 org-time 索引 |
| 组织子树当前人员 | closure → `sys_user_org` → user | 不依赖逗号 ancestors 模糊匹配 |
| 组织子树历史事实 | closure 得到 descendant org,join fact.org_id_at_event | 可使用 semi-join,避免先拉全量 userId |
| 当前用户归属聚合 | fact.user_id join 当前 primary membership | 必须在 API 中标记 current-org 口径 |
| 告警工作队列 | tenant + org/status/severity + occur_at keyset | 避免 offset 扫描深页 |
| 用户收件箱 | tenant + recipient user + sent_time/response_status | 主记录与 delivery 明细分层查询 |
| 大屏 | 组织/租户分钟级投影 + realtime event cursor | PG 可重建,Redis 只加速 |
### 7.3 读模型
建议按用途建设窄投影,不建设一个全能宽表:
| 投影 | 主键 | 消费端 |
|---|---|---|
| `rm_user_health_current` | customer + user | Phone、Admin 用户概览 |
| `rm_org_health_hourly/daily` | customer + org + bucket | Admin、Bigscreen 趋势 |
| `rm_device_current_state` | customer + device | Admin、Bigscreen、Phone |
| `rm_alert_workload` | customer + org + status + severity | Admin、Bigscreen |
| `rm_message_inbox` | customer + user + message | Phone、Watch |
| `rm_bigscreen_snapshot` | customer + filter signature | Bigscreen |
投影统一消费 §3.2 定义的 outbox 事件,不再建立第二条领域事件链:
```text
domain transaction
-> PostgreSQL facts + t_health_event_outbox
-> HealthEventOutboxWorker
-> required Spring Event publisher -> 当前进程内消费者/低延迟投影
-> non-required Kafka shadow publisher -> ClickHouse 与对账
-> Kafka 晋级后 -> 跨进程 durable read-model consumers
```
Kafka 晋级前,shadow 失败不能改变 outbox 已完成语义,跨进程读模型也不能假设 Kafka 是完整事实源;晋级门禁至少包含事件 ID 一致、PG↔ClickHouse 数量/字段对账、重放幂等、消费延迟和故障恢复。每张投影必须有重建任务、对账 SQL、延迟指标、版本字段、source outbox event id 和 PG fallback。现有 Bigscreen Redis 热缓存继续作为从 PG/canonical snapshot 重建的末级缓存。
### 7.4 分页、缓存和隐私
- 时序、告警、消息使用 `(time, id)` keyset pagination;管理端固定页码场景可以保留浅层 offset。
- 缓存键必须包含 tenant、授权范围摘要、隐私模式和查询版本。
- 不缓存未经脱敏的 Bigscreen 响应到 aggregate-only/pseudonymized key。
- 人数低于租户配置的最小聚合阈值时,不返回组织健康统计明细,防止小样本反推个人。
---
## 8. 多终端呈现模型
### 8.1 终端矩阵
| 终端 | 主体 | 默认范围 | 主要能力 | 输出策略 |
|---|---|---|---|---|
| Admin | HUMAN | 角色绑定的 tenant/org/user | 配置、用户管理、健康工作台、告警处置、消息运营、设备管理 | 私有字段按 permission + purpose 控制 |
| Bigscreen | VIEWER/HUMAN | 固定 tenant,可选受限 org | 汇总、实时态势、有限告警处置 | 默认 pseudonymized;aggregate-only 可无人值守 |
| Phone | HUMAN | SELF | 个人健康、本人设备、本人消息/告警;管理者显式切换 managed scope | 不接受任意 user/customer 覆盖当前身份 |
| Watch/手环 | DEVICE | 当前 binding subject | 上传、配置拉取、少量消息、回执 | 低带宽 DTO、短 token、无自由组织查询 |
| 无人机/机器人 | DEVICE/SERVICE | tenant/org/mission | 遥测、任务、受控下行命令 | capability allowlist + 强审计 |
| 第三方系统 | SERVICE | grant 中声明 | 事件推送、批量查询、Webhook | API scope、限流、签名、字段最小化 |
### 8.2 Surface Capability
每个终端先获取能力,再呈现 UI:
```json
{
"surface": "phone",
"authzVersion": 42,
"features": {
"health.self.read": true,
"health.org.read": false,
"alert.self.ack": true,
"alert.reassign": false,
"device.bind.request": true
},
"privacyMode": "private",
"limits": {"historyDays": 90, "pageSize": 50}
}
```
能力由租户授权、角色权限、组织 assignment、终端类型、设备能力和 license 共同计算。前端隐藏功能只改善体验,后端仍必须鉴权。
### 8.3 BFF 收敛
```text
Domain Services
HealthQueryService
DeviceRegistryService
AlertCenterService
MessageCenterService
|
+-- AdminHealthWorkbenchController
+-- BigscreenController/SSE
+-- MobileApiController
+-- DeviceIngressController
```
收敛规则:
- Admin workbench 与 Health V2 共用 `AuthorizedSubjectScope`;
- Bigscreen 的 `tenantId/orgId` 必须与 viewer/session scope 取交集;
- Phone 默认强制当前 userId,只有拥有 manager capability 时才能传 managed userId;
- Watch/手环由设备凭据和 binding 注入 customer/user/org,body 中同名字段一律覆盖或拒绝;
- 所有端的告警动作调用同一状态机,消息响应调用同一响应服务。
---
## 9. 数据库与本地 SQL 方案
### 9.1 P0 关系表修正
建议为以下关系表补 `customer_id NOT NULL` 并回填:
- `sys_user_role`
- `sys_role_menu`
- `sys_role_permission`
理由:让 RLS、唯一约束、缓存失效、租户清理和查询索引使用同一租户键,不再依赖多层 join 才能证明归属。
同时:
- `sys_user_org` 增加独立 `is_primary`,迁移后停止用 `principal` 表示主组织;
- 新建 `sys_org_manager`,或至少把 `principal` 明确定义为 manager 并修正唯一约束;
- `sys_user_position` 保证现有 `customer_id` 正确写入,补 `org_id`、有效期和真实 tenant-aware 索引/唯一约束;
- `t_device_heartbeat/t_device_message_department` 纳入一致的 RLS 和受限角色测试;
- 资源关系表增加 tenant-aware foreign key/校验触发器或 service guard;
- RLS policy 使用 `NULLIF(current_setting(...), '')` 和显式 elevation,所有策略由受限角色回归测试。
### 9.2 RLS 会话变量与连接池纪律
当前 `TenantAwareDataSource` 在连接借出时使用 `set_config('app.current_tenant', value, false)`,这是 **session-scoped**,并在代理 `Connection.close()` 时清空。它能作为迁移基础,但连接池安全必须满足以下硬约束:
- `SET LOCAL` 只用于已经进入显式事务的业务路径;不能在 Hikari checkout 的 auto-commit 连接上盲目替换为 `SET LOCAL`,否则语句结束后上下文立即失效;
- 非事务或尚未完成事务化的路径继续使用 session-scoped set 时,连接归还前必须 reset;reset 失败的物理连接必须 evict/关闭,禁止只记录 warning 后回池;
- tenant A、TARGET/ALL、tenant B 每次 checkout 都显式覆盖上下文,不能继承连接上一次的值;elevation 还必须独立校验受控角色和审计原因;
- 异常、rollback、timeout、异步执行、嵌套事务和只读事务均进入 reset/evict 测试;不得把 tenant ThreadLocal 自动传播等同于数据库 session 已正确设置;
- 用同一物理连接按 `tenant A -> exception -> tenant B`、`CURRENT -> ALL -> CURRENT` 顺序做回归,并以 `NOBYPASSRLS` 用户验证查询结果和 `current_setting`。
目标态优先让需要 RLS 的访问进入明确事务,在事务开始后执行 `SET LOCAL app.current_tenant = ...`;仍需 auto-commit 的少数入口保留严格的 set/reset-on-return 封装。两种模式都必须 fail closed:上下文缺失、非法或清理失败时不能继续执行下一租户请求。
### 9.3 本地执行约束
本仓库本地开发不依赖 Flyway:
1. 可复用、幂等 SQL 放到 `ljwx-boot/scripts/sql/`;
2. 通过 `ljwx-boot/scripts/apply-local-sql.sh` 或 `docker exec ... psql` 应用;
3. 每个结构变更提供 precheck、apply、verify、rollback/兼容说明;
4. 验证 `flyway_schema_history` 不存在也不影响本地启动;
5. 生产迁移与本地脚本保持同一 DDL 语义,但不要求本地伪造 Flyway 历史。
---
## 10. 分阶段落地计划
阶段以依赖和退出条件驱动,不以“写完代码”作为完成标准。
### Phase 0:安全语义止血与契约冻结(1–2 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-01 统一词汇与 ADR | 固化 primary org、org manager、position、role、menu、subject、actor、event org 语义 | 代码、DB、API 文档不再混用 principal/primary |
| IA-02 AccessContext | 新增 resolver/filter,统一 effective tenant、user、surface、device、privacy、assignment、authzVersion tuple | Admin/Phone/Bigscreen/Watch 请求均可输出同一审计上下文;上下文中不存在可误用的扁平 dataScope |
| IA-03 DataScope fail-closed | `CUSTOM` 空配置返回空集;按 action 逐 assignment 编译 scope;实现 `AuthorizedSubjectScope`;先接 Health V2、Admin workbench、Mobile health、Bigscreen | 同租户跨组织/跨用户负向测试全部 403/404;SELF + 多组织角色组合结果符合 §5.3 |
| IA-04 组织角色接线 | 登录加载 `sys_user_role + sys_user_org_role` assignments;permission hint 可并集,资源访问必须由同一 assignment 同时授予 action 和 scope | 组织角色在绑定组织有效,在兄弟组织无效;不能跨 assignment 拼接 permission/scope |
| IA-05 菜单/权限收敛 | 路由装配应用 tenant override;增加 menu-permission 一致性校验;缓存版本化 | 隐藏/别名/排序生效,API 权限不依赖菜单 |
| IA-06 RLS 真实验证 | 修正关系表 tenant key/policy;落实事务 `SET LOCAL` 或 session set/reset/evict 纪律;用 `NOBYPASSRLS` 角色跑 CURRENT/TARGET/ALL 和连接复用测试 | 超级用户与受限角色业务结果一致;tenant A 的物理连接在异常/ALL 后不能污染 tenant B;越权查询被拒绝 |
| IA-07 Device Auth P0 | 以 `t_device_bind_approval` 为 provisioning 授权锚点,bootstrap possession challenge 后换取短期 token;签名含 method/path/body hash/timestamp/nonce | 审批不能替代设备身份证明;只持有 SN 无法上传、拉配置或收消息;bootstrap 和业务请求重放均被拒绝 |
| IA-08 负向契约矩阵 | tenant×org×user×surface×action 参数化测试 | CI 强制覆盖横向/纵向越权与伪造维度 |
| IA-09 授权版本服务 | 建立 platform/tenant/user version 事实表和唯一递增服务;同事务写 authorization outbox,多实例清缓存并对高风险收权撤销 Session/token | 任一实例修改授权后其他实例立即拒绝旧高风险凭据;普通缓存失效可由版本 + TTL + DB 回源证明 |
Phase 0 兼容策略:旧接口保留一个灰度周期,记录客户端传入维度与服务端解析维度的差异;先告警再拒绝,设备认证例外应通过明确的临时 allowlist,而不是永久信任 SN。
### Phase 1:关系与资源归属收敛(2–3 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-11 人员组织模型 | 拆分 `is_primary` 与 org manager;实现有效期、调动和状态端点;发布 assignment changed event | 调动单事务完成,权限/缓存/设备/告警影响可审计 |
| IA-12 岗位模型 | 岗位绑定组织和有效期;风险等级/权重进入统一策略服务 | 岗位变化不直接改变权限,健康规则能按事件时岗位取值 |
| IA-13 设备绑定事实源 | `t_device_user` 变为时间化 binding;`sys_user.device_sn/t_device_info.user_id` 降级为投影 | 同设备不能同时绑定两人,用户支持多设备,历史可追溯 |
| IA-14 资源信封 | 健康、告警、消息补齐 actor/subject/source/event-org 语义和字段 | 任一告警可追溯到原始事实和设备;消息可追溯到告警/发布人 |
| IA-15 查询索引 | 对候选索引做真实数据 EXPLAIN;只上线证明有效的组合/部分索引 | 核心组织/用户查询 P95 达标且写放大可接受 |
| IA-16 模型去重 | 选定每张核心表的 canonical Entity/Mapper,逐步删除重复模型和双列映射 | 同一物理列只由一个 canonical 属性映射 |
| IA-17 租户 Blueprint | 模板版本、差异预览、冲突处理、幂等同步和回滚 | 平台模板升级不覆盖租户自定义,新增权限可按 entitlement 灰度 |
### Phase 2:统一查询服务与可重建投影(2–4 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-21 SubjectScopeQueryService | 统一 current/event scope、org subtree、user set、keyset pagination | Controller 不再自行拼 customer/user/org 条件 |
| IA-22 领域查询 API | 收敛 Health/Device/Alert/Message 查询 DTO 和错误语义 | 四端同一资源得到一致权限与状态 |
| IA-23 组织/用户读模型 | 复用同一 outbox 事件;建设 health current、org daily、device current、alert workload、message inbox;Kafka 达到 shadow 晋级门禁后再承载跨进程 durable consumer | 每个投影有 source event id、幂等、rebuild、diff、lag、PG fallback;Spring/Kafka 不产生平行事实 |
| IA-24 Bigscreen 收敛 | 只改 realtime/snapshot/rebuildCache/examSummary/interventionSummary 等 query-first 端点,使 tenant/org 与 viewer scope 取交集;保留 `getAssignees/urgeExam/getAlertDetail` 的 `effectiveTenantSafe()` 参考实现;快照与 SSE cursor 对账 | 所有未收敛端点的 query tenantId 不能扩权,已收敛端点无重复改造,隐私模式不串缓存 |
| IA-25 可观测性 | 授权拒绝原因、scope 大小、查询延迟、缓存命中、投影 lag | 能区分认证失败、租户拒绝、数据范围拒绝和隐私裁剪 |
### Phase 3:多终端能力平台(2–3 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-31 Capability API | surface/tenant/role/device capability 合并,版本化缓存 | Admin/Phone/Bigscreen 可基于同一能力事实呈现 |
| IA-32 Viewer Principal | 大屏短期 token,固定 tenant/org/privacy/action | 无人值守大屏无普通管理员长期 token |
| IA-33 Device Principal | 手表/手环设备凭据轮换、撤销、离线队列和命令回执 | 设备遗失后可单独撤销,不影响用户账号 |
| IA-34 Edge Principal | 机器人/无人机/网关 capability、mission、command 权限 | 遥测与控制分权,所有命令可审计和幂等 |
| IA-35 第三方授权 | service account、API scope、Webhook 签名、字段最小化 | 外部系统不能复用平台管理员 token |
### Phase 4:隐私治理与规模化
- 用途/同意/字段级策略和紧急 break-glass;
- 小样本聚合保护、导出水印、敏感字段访问审计;
- 租户模板版本、差异预览、灰度同步和回滚;
- 策略模拟器:给定 actor/action/resource 输出允许/拒绝及每层证据;
- 大租户按时间分区、冷热分层、归档和投影容量治理。
---
## 11. 测试与验收矩阵
### 11.1 必测主体
- 平台超管 CURRENT/TARGET/ALL;
- 租户管理员;
- 部门负责人;
- 组织角色用户;
- SELF 普通用户;
- CUSTOM scope 用户;
- Bigscreen viewer;
- 已绑定/解绑/伪造/撤销设备;
- 内部 service 与第三方 service account。
### 11.2 必测资源
- 用户列表与详情;
- 菜单、permission、岗位和角色分配;
- 健康明细、时序、分析、组织聚合;
- 设备列表、绑定、心跳和同步状态;
- 告警列表、详情、确认、关闭、转派;
- 消息发布、收件箱、已读/确认、组织受众统计;
- Bigscreen snapshot/realtime/SSE/action。
### 11.3 四类断言
1. **正向**:合法主体在合法范围内可读写。
2. **横向越权**:同租户兄弟组织、其他用户、其他设备、其他收件人均拒绝。
3. **纵向越权**:普通用户不能分配高权限角色、切换租户、执行大屏/管理员动作。
4. **参数伪造**:body/query/header 的 customer/user/org/device 与服务端上下文冲突时拒绝并审计。
数据库测试必须同时运行:
- 超级用户,仅验证功能;
- `NOBYPASSRLS` 应用角色,验证真实 RLS;
- elevation/ALL 的受控事务角色,验证跨租户运维路径。
---
## 12. 性能与 SLO 建议
| 场景 | 目标 |
|---|---|
| 登录授权快照 | P95 < 200 ms,缓存命中 < 30 ms |
| Admin 组织树/用户筛选 | P95 < 300 ms |
| 用户健康时序 7 天 | P95 < 300 ms |
| 组织聚合 30 天 | 投影命中 P95 < 500 ms,PG fallback < 2 s |
| Bigscreen realtime | 新事件可见 < 5 s |
| Bigscreen snapshot | 缓存命中 < 200 ms,PG 重建 < 3 s |
| 告警工作队列 | P95 < 300 ms |
| Phone 收件箱 | P95 < 300 ms |
| 授权策略判定 | 缓存命中 P95 < 20 ms |
这些是首版容量目标,最终以生产数据量、租户规模和隐私策略评审后固化。
---
## 13. 实施顺序与依赖
```mermaid
flowchart LR
A[IA-01 语义 ADR] --> B[IA-02 AccessContext]
B --> C[IA-03 Scope fail-closed]
B --> D[IA-04 组织角色]
D --> E[IA-05 菜单/权限]
B --> V[IA-09 授权版本]
D --> V
E --> V
C --> F[IA-08 负向矩阵]
D --> F
E --> F
V --> F
B --> G[IA-07 Device Auth]
C --> H[IA-11 人员组织关系]
H --> I[IA-13 设备绑定]
H --> J[IA-14 资源信封]
I --> J
J --> K[IA-21 统一查询]
K --> L[IA-23 读模型]
L --> M[IA-31 多终端能力]
```
最小可交付切片不是“把所有表重构完”,而是:
```text
AccessContext
+ fail-closed DataScope
+ 组织角色接入登录
+ platform/tenant/user 授权版本与高风险收权
+ Health Workbench/Mobile/Bigscreen 三个高风险查询接线
+ 受限 RLS 与同租户越权负向测试
```
完成该切片后,后续设备绑定、资源信封、读模型和新终端才能在稳定授权边界上迭代。
---
## 14. 不在本轮做的事情
- 不在架构第一阶段引入独立 IAM 微服务;先在现有 Spring 模块内建立清晰接口与边界。
- 不把所有历史表一次性重命名为新字段;先统一逻辑语义和访问层,再渐进迁移物理模型。
- 不把 Redis 变成授权或健康事实源。
- 不让前端菜单替代后端权限。
- 不让岗位自动拥有管理员权限。
- 不为了支持未来设备而把 `user_id` 设为所有设备资源的必填字段。
- 不在本地创建或修复 `flyway_schema_history`。
---
## 15. 代码审计锚点
- 租户/用户上下文:`GlobalRequestInterceptor`、`UserContext`、`TenantContext`
- 登录角色与权限:`SysUserServiceImpl.saveUserToSession()`、`SysRoleMapper.xml`、`SysPermissionInterfaceImpl`
- 菜单路由:`AuthenticationFacadeImpl.queryUserRouteWithUserId()`、`SysMenuServiceImpl`
- 组织角色:`UserOrgRoleService`、`SysUserOrgRole`
- 数据范围:`DataScopeInterceptor`、`ScopeQueryFactory`
- 统一授权上下文:`AccessContext`、`GrantAssignment`、`AuthorizedSubjectScope`、`AccessContextHolder`、`RequestAccessContextResolver`
- Assignment 授权:`DefaultGrantAssignmentLoader`、`AssignmentResourceAuthorizer`、`PermissionSubjectScopeResolver`
- 管理边界:`AuthorizationChain`、`AdminBoundaryGuard`、`OrgScopeGuard`、`RoleDelegateGuard`
- 组织关系:`SysUserOrgServiceImpl`、`PrincipalBasedOrgManagerResolver`
- 健康工作台:`AdminHealthWorkbenchController`、`HealthWorkbenchServiceImpl`
- 多终端:`BigscreenController`、`MobileApiController`、`WatchApiController`
- 业务资源:`TUserHealthData`、`TDeviceInfo`、`TDeviceUser`、`TDeviceHeartbeat`、`TAlertInfo`、`TDeviceMessage`、`TDeviceMessageDetail`
- RLS:`R__rls_policies.sql`
---
## 16. 实施跟踪与版本记录
IA 编号是稳定的工作包标识,父跟踪项为 [GitHub Epic #383](https://github.com/lingjingwanxiang/ljwx-health/issues/383)。后续按 IA 工作包创建或复用 issue;issue 标题保留 IA 编号,退出条件原样进入 acceptance criteria,依赖关系按 §13 维护。创建前必须基于实时 issue/PR 状态做去重,不能仅凭文档预分配可能冲突的 issue number。
### 16.1 实施台账
| 工作包 | 状态 | 已落地 | 尚未完成 |
|---|---|---|---|
| IA-01 | IN_PROGRESS | v1.1 已固化 actor/subject、effective tenant、assignment、event org 等术语;公共代码引入同名强类型 | DB 字段、API 契约和旧 `principal/primary` 用法尚未全部迁移 |
| IA-02 | IN_PROGRESS | `AccessContext/GrantAssignment/AuthzVersion` 不可变契约;请求入口解析 HUMAN、platformAdmin、CURRENT/TARGET/ALL、server-bound surface、trace;ThreadLocal 前置清理和 completion 清理;授权资源首次访问时形成 request-local resolved snapshot | privacy policy、真实 authzVersion 尚未装入快照 |
| IA-03 | IN_PROGRESS | `CUSTOM`、空组织列表、缺失 SELF user 改为 fail closed;`AuthorizedSubjectScope` 按 permission 合并完整 assignments;Admin Health Workbench context/records/series/analysis/actions 已裁剪可见组织树、用户选项并在领域查询前校验 resource scope;Mobile `/mobile/health/data` 从 AccessContext 固定 operator/effective tenant;Bigscreen realtime/snapshot/cache rebuild/exam/intervention/SSE 租户聚合与 Admin alert action/detail/assignees 从 permission-specific scope 固定 effective tenant,非 TENANT_ALL assignment 与 query tenant 扩大均返回 HTTP 403;SSE 复用已落库的 `bigscreen:realtime:read`,不再引用不存在的 subscribe permission;Viewer alert detail 在 Facade 前按持久化告警 tenant/org 校验 token org scope;Viewer create/bootstrap/revoke/revoke-tenant 管理操作按 `bigscreen:viewer:manage` 编译 tenant-wide subject scope;Health V2 score trend/distribution/ranking 在进入 Facade 前按 endpoint permission 校验 user/org/tenant 聚合范围并覆盖 effective tenant;Health Profile V2 comprehensive/daily/behavior/risk 取消角色白名单,改由 `t:user:health:data:page` 的同 assignment subject scope 校验目标用户;Alert V2 list 固定服务端 tenant/user/org 查询维度,detail/handle/assign/status 在读写前将 endpoint permission 与持久化告警的当前 user/org 关系求交,批量写先完成全量授权 | Mobile 其余资源入口、Bigscreen viewer 聚合组织过滤与组织范围 Viewer 启用、其余 Health/Message/Device 入口的组织/用户/资源范围及真正的组织级聚合仍待逐端接线 |
| IA-04 | IN_PROGRESS | `sys_user_role + sys_user_org_role` 分别装载为带 source/boundary/anchor 的 assignment;显式绕过 tenantLine 读取指定角色后严格校验关系租户、角色租户、组织锚点和 dataScope,仅兼容显式 `is_template=true` 的平台模板直绑;资源访问由同一 assignment 同时授予 action 与 scope;组织角色的 TENANT_ALL 仍受绑定组织子树约束;登录菜单已消费 resolved assignment role set | 其余高风险资源入口尚未切换;存量平台模板直绑应在 IA-05 迁移为租户角色副本 |
| IA-05 | IN_PROGRESS | Admin 路由装配改为读取 IA-04 resolved assignments,直属角色与组织角色的菜单/permission hint 均可投影;`sys_menu_tenant_override` 的隐藏、别名、排序进入租户路由,隐藏父菜单会隐藏整棵子树,别名清除 i18n key;应用覆盖前复制角色菜单对象,避免共享缓存跨租户污染 | menu-permission 全库一致性门禁、租户模板直绑迁移、navigation/authz version 缓存键与多实例失效尚未完成 |
| IA-07 | IN_PROGRESS | challenge 使用按设备 bootstrap proof 并单次消费;新增 15 分钟短 token、method/path/body hash/timestamp/nonce HMAC、Redis replay key、解绑撤销与 disabled/monitor/enforce 灰度模式;Redis 仅保存 token hash | 生产 KMS/Vault 按设备凭证、Phone/Watch 安全 provisioning、强制绑定签名及 monitor 到 enforce 的实证切换尚未完成 |
台账状态按退出条件判断,不能因类或接口已经创建就标记 DONE。2026-07-20 当前 changed slices 覆盖 AccessContext/请求解析、DataScope fail-closed、assignment loader/evaluator、Workbench/Mobile/Bigscreen/Health V2 资源 guard、assignment-aware tenant navigation 和短期设备认证;验证结果以 Epic #383 的进展记录为准。
### 16.2 版本记录
| 版本 | 日期 | 状态 | 变更 |
|---|---|---|---|
| v1.0 | 2026-07-10 | Proposed | 初版总体架构、事实核查和 IA-01~IA-35 落地计划 |
| v1.1 | 2026-07-10 | Accepted | 标注组织管理现状与 Bigscreen 已收敛端点;补齐 assignment 合并语义、RLS 连接池纪律、Outbox/Kafka 单事件源、授权版本属主、设备 provisioning 和实施跟踪规则 |
| v1.2 | 2026-07-10 | Accepted | 关联 Epic #383;记录 IA-04 assignment 装载、同 assignment 授权判定、permission-specific subject scope 与 Health Workbench 全入口接线 |
| v1.3 | 2026-07-19 | Accepted | 记录 IA-05 assignment-aware tenant navigation 与 IA-07 短期设备请求认证的阶段交付和剩余生产门禁 |
| v1.4 | 2026-07-19 | Accepted | 记录 IA-03 Mobile health subject scope 首段接线:服务端主体定界、兼容 hint 匹配与 HTTP 403 越权拒绝 |
| v1.5 | 2026-07-19 | Accepted | 记录 IA-03 Bigscreen 租户聚合首段接线:permission-specific tenant-wide scope、effective tenant 改写与 query tenant 扩大拒绝 |
| v1.6 | 2026-07-19 | Accepted | 记录 IA-03 Health V2 score 端点接线:permission-specific user/org/tenant 范围、effective tenant 改写与冲突维度拒绝 |
| v1.7 | 2026-07-20 | Accepted | 记录 IA-03 Health Profile V2 画像读接线:移除角色白名单,以现有健康数据读取 permission 编译 user/org/tenant subject scope,并统一 HTTP 403 越权语义 |
| v1.8 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen SSE 接线:复用 realtime read permission 编译 tenant-wide subject scope,以服务端 effective tenant 打开 replay/live channel,并拒绝跨租户 query hint |
| v1.9 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen Admin 告警入口接线:action、detail、assignees 在调用 Facade 前按各自 permission 要求 tenant-wide subject scope |
| v1.10 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen Viewer 告警详情资源校验:token org scope 与告警持久化 tenant/org 在 Facade 前求交,未知或越界资源统一拒绝 |
| v1.11 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen Viewer 管理面接线:create/bootstrap/revoke/revoke-tenant 按 `bigscreen:viewer:manage` 编译 tenant-wide assignment scope,非租户级授权在进入 session service 前拒绝 |
| v1.12 | 2026-07-20 | Accepted | 记录 IA-03 Alert V2 接线:list 固定服务端 tenant/user/org 维度,detail/handle/assign/status 在 Facade 或写服务前校验持久化告警与当前 user/org subject scope,批量更新先授权全部资源 |
@@ -0,0 +1,49 @@
{
"artifact_id": "lingjingwanxiang__ljwx-health-035afcfc16da",
"documents": [
{
"approved_by": null,
"audience": [
"claude",
"codex"
],
"checksum": "8afab9e82cca677ba55d07a014541049524b333a35f906a94d264e8c5b9f5586",
"content_path": "documents/ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md.md",
"doc_type": "design_spec",
"document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md",
"effective_from": null,
"excluded_from_retrieval": false,
"expires_at": null,
"op": "upsert",
"owner": "team-health",
"product_area": "ljwx-health",
"reason": null,
"review_status": "approved",
"source_id": "ljwx-health-architecture-ssot",
"source_path": "docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md",
"source_ref": "lingjingwanxiang/ljwx-health@035afcfc16dad7c896d536c6edcf68117a6767a3:docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md",
"source_type": "repo_doc",
"tags": [
"ljwx-health",
"architecture",
"ssot"
],
"tier": "ssot",
"title": "多租户身份、权限、健康资源与多终端总体架构",
"version": "035afcfc16dad7c896d536c6edcf68117a6767a3",
"visibility": "internal"
}
],
"export_mode": "changed",
"generated_at": "2026-07-19T19:00:33.067422+00:00",
"schema_version": "0.1.0",
"source": {
"commit": "035afcfc16dad7c896d536c6edcf68117a6767a3",
"ref": "refs/heads/main",
"repo": "lingjingwanxiang/ljwx-health",
"run_attempt": 1,
"run_id": 29699745028,
"service": "ljwx-health",
"workflow": "knowledge-export"
}
}
@@ -0,0 +1 @@
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "8afab9e82cca677ba55d07a014541049524b333a35f906a94d264e8c5b9f5586", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@035afcfc16dad7c896d536c6edcf68117a6767a3:docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "多租户身份、权限、健康资源与多终端总体架构", "version": "035afcfc16dad7c896d536c6edcf68117a6767a3", "visibility": "internal"}
@@ -0,0 +1,47 @@
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md:chunk:0000", "chunk_index": 0, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 27, "source_start_line": 1, "text_hash": "6277ed9a0285fb808ead5930dfbe8fe30a1963b376f654b5ecee56ed0f5ec92e", "token_count": 237, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md:chunk:0001", "chunk_index": 1, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 56, "source_start_line": 29, "text_hash": "d67976b182b621875a4a64292495121c727123b376ace8c7e30350acafd4743f", "token_count": 288, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md:chunk:0002", "chunk_index": 2, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 85, "source_start_line": 58, "text_hash": "6e12d53a5ad92f98a1c4e04cc593d99e9e17479a6962ab42948cf67360ec0271", "token_count": 286, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md:chunk:0003", "chunk_index": 3, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 89, "source_start_line": 87, "text_hash": "5085b943bff5c45a435940144923fd1e3fcc33b8fa2f2ee69d7bf3dd0e75d3c6", "token_count": 52, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md:chunk:0000", "chunk_index": 0, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 34, "source_start_line": 1, "text_hash": "570a55d7b6f87a3795d46bf6201b4fe9a22fa1a1806168bcccc8c3f5e81ec745", "token_count": 294, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md:chunk:0001", "chunk_index": 1, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 46, "source_start_line": 36, "text_hash": "a311492ddc50c6369ef76fec96d30207cc241cb95ffeef9e42be6d6fbaade641", "token_count": 224, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md:chunk:0002", "chunk_index": 2, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 57, "source_start_line": 48, "text_hash": "f88e8c79fa07904c8f90da3ee37f8fb9ae9716a31447fdac989a291e2e6b6292", "token_count": 148, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md:chunk:0003", "chunk_index": 3, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 73, "source_start_line": 59, "text_hash": "d09e5efd0a82c86478c87ed611588c94c78c00f869c4a4465d37a0702f4517c0", "token_count": 208, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md:chunk:0004", "chunk_index": 4, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 80, "source_start_line": 75, "text_hash": "e3be1b40f8376c84fa04dc71f75205007c0bdf7fa6b991b6bfceb864622ef0ee", "token_count": 340, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md:chunk:0005", "chunk_index": 5, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 105, "source_start_line": 82, "text_hash": "bbeb2f8ddd1631e608dbf34982accfae6b4b497dc8242f91ce956363ec324ca6", "token_count": 269, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0000", "chunk_index": 0, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 17, "source_start_line": 1, "text_hash": "de214ec9e24d6905739992c35b9f57cbf6eaf25c8bf7c3fdb8d9465b1b1eb0b4", "token_count": 131, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0001", "chunk_index": 1, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 36, "source_start_line": 19, "text_hash": "00a8e2447a0e6c7575d39c75d3a678b76e4c6c5103380bfc7522fba4c6f7db4f", "token_count": 274, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0002", "chunk_index": 2, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 49, "source_start_line": 38, "text_hash": "5a7c3dcce8725c63f5a4bcf09db7ea272d3c7acd6269b063fcd02707dd53ad05", "token_count": 311, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0003", "chunk_index": 3, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 51, "source_start_line": 51, "text_hash": "de15131e2e4c48517dcbedd4bdf67374a19af9664e145de5abc7eb6e624850d5", "token_count": 4, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0004", "chunk_index": 4, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 66, "source_start_line": 53, "text_hash": "000ddd7a6379bc815d654af631c1f8b98ac28bf67d12c318b4010ced6bac0a1d", "token_count": 708, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0005", "chunk_index": 5, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 93, "source_start_line": 68, "text_hash": "86bb0d253b8363508c4e6e3b9b760f4a7e54ddaae08e96a86ad9a5c39b997b14", "token_count": 303, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0006", "chunk_index": 6, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 136, "source_start_line": 95, "text_hash": "4d53ba8d24ef9e3c8f454086bc414fdc00afef388168ed7a0bc13c8e8ab7bbb4", "token_count": 261, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0007", "chunk_index": 7, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 171, "source_start_line": 138, "text_hash": "7efd0f34dd78e8d98df188e8d0f78f0739eee92a73a5a14622312504ac3fcf2a", "token_count": 268, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0008", "chunk_index": 8, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 200, "source_start_line": 173, "text_hash": "3b17df18fdb6134a59154d6c95fabe093871e3ade095392676768d72ea41d903", "token_count": 240, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0009", "chunk_index": 9, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 241, "source_start_line": 202, "text_hash": "a6d33fa10ad46ff9eee4efc4ab92bfd5319740dd3d2dc0b3767e8ba3ae5c5896", "token_count": 299, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0010", "chunk_index": 10, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 288, "source_start_line": 243, "text_hash": "8a45e77919a10bc133ecc9c9bbc0fc160d1018d0e37ab43e84389a3996148b16", "token_count": 283, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0011", "chunk_index": 11, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 320, "source_start_line": 290, "text_hash": "77466f73567cb0b35614f806429a1e1da01cdf18e82635308c44ecc6fb2d2739", "token_count": 240, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0012", "chunk_index": 12, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 347, "source_start_line": 322, "text_hash": "093d608d9bcadb7eb7d53cbae6436b328c2564e49be1c794f292bfe02a9650b1", "token_count": 252, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0013", "chunk_index": 13, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 378, "source_start_line": 349, "text_hash": "e491e43cd1b8a58e58eb67a50e554edf7b96b7aade05c072cfcb286f6bc91df4", "token_count": 266, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0014", "chunk_index": 14, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 411, "source_start_line": 380, "text_hash": "2de9818f8db4e9e5c4fba0e5adc18103e303e96d16019ed6af031285cc91b3f5", "token_count": 297, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0015", "chunk_index": 15, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 434, "source_start_line": 413, "text_hash": "ef8864dd35b8254e92e2e1a4d7902d73d70a5fc228c4c6dd93c5f0a30612d086", "token_count": 279, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0016", "chunk_index": 16, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 462, "source_start_line": 436, "text_hash": "aebcdaccb40e7fe532fcf854fbda9b34868a78bbdeb0696f7688b6aec8387391", "token_count": 174, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0017", "chunk_index": 17, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 483, "source_start_line": 464, "text_hash": "ccdc79b09612e809b6c19ca84e92e7a4333204af8d97db918ecce5f3052d404d", "token_count": 159, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0018", "chunk_index": 18, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 506, "source_start_line": 485, "text_hash": "2c58cf322b6231c62f4ee8f907cd6c262338345d1c2876c4a5f67ca0feceea7b", "token_count": 294, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0019", "chunk_index": 19, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 538, "source_start_line": 508, "text_hash": "e5e8b8e99e43db580814bfb46029f5b06407c98fef5a793185062d3a5442a60a", "token_count": 302, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0020", "chunk_index": 20, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 575, "source_start_line": 540, "text_hash": "b3c1d250d631e2b28521df2d18361684c9fd42ca1cf8fe6cd565cc53859af31a", "token_count": 300, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0021", "chunk_index": 21, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 601, "source_start_line": 577, "text_hash": "4ae27bb639ccdd1de74c2bba6cc7b6b0550ca809be46b6b09ca0316e6546c933", "token_count": 269, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0022", "chunk_index": 22, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 641, "source_start_line": 603, "text_hash": "cb559e40c3f71914d94223e9ef304796e83da03283d46d753db3389768145c8a", "token_count": 303, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0023", "chunk_index": 23, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 682, "source_start_line": 643, "text_hash": "71f8b628b1e74cd2231bd1684e504c318338c2e0f70ba46d868f8392de676c33", "token_count": 213, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0024", "chunk_index": 24, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 699, "source_start_line": 684, "text_hash": "7f5562961273a2696d56c24e77e3d233758c7cb32af676332a053e9cea4faf0e", "token_count": 282, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0025", "chunk_index": 25, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 719, "source_start_line": 701, "text_hash": "ba8a12d9b89e0290530e11024fae15f01f2a324e5d45ded6d3cafab088f75bef", "token_count": 136, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0026", "chunk_index": 26, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 731, "source_start_line": 721, "text_hash": "0c868bf5bc0462de652ac2ad10b9153bee2fd972acc19697240acc72c98f1bf2", "token_count": 381, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0027", "chunk_index": 27, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 747, "source_start_line": 733, "text_hash": "fc3bd4c1fe0224346027fce4fbe01cf1913316077e335de1dda15809605c2422", "token_count": 218, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0028", "chunk_index": 28, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 757, "source_start_line": 749, "text_hash": "c57f811598606c93d8f507a8f6478d5bf7a3cabc5359a3d0d5c5a633018d2943", "token_count": 220, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0029", "chunk_index": 29, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 801, "source_start_line": 759, "text_hash": "4f407409d00a0c8b3a855aa646a038ecc527a188fd40c664ffd37d7ce1cc4ffb", "token_count": 262, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0030", "chunk_index": 30, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 859, "source_start_line": 803, "text_hash": "88dc8f369429cce81aa117365ea3d321cf8c63c65588819200aba1c9d6954c60", "token_count": 288, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0031", "chunk_index": 31, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 886, "source_start_line": 861, "text_hash": "a509a5cf7b3198dc3a2ea241f98c1d672960a372c912807c12550bd51cc47d67", "token_count": 119, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0032", "chunk_index": 32, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 904, "source_start_line": 888, "text_hash": "b984b9da413bf43e367ca026d9fef63a948bf3b9d0879264e44bea6696d2cd97", "token_count": 261, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0033", "chunk_index": 33, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 908, "source_start_line": 906, "text_hash": "05da055d08de3500cc429d3254a9103562039ff290efb0b0aba61dc1945aee70", "token_count": 62, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0034", "chunk_index": 34, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 917, "source_start_line": 910, "text_hash": "eef004c1a99d8773f46e6ee18c47326fab67a9dfba5567fa33a00c0f9452bd66", "token_count": 824, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0035", "chunk_index": 35, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 921, "source_start_line": 919, "text_hash": "5b66d7e4d1af519d0ced19d73d353bc5ee611142b3ca94fa0cefd8e447ddf6b5", "token_count": 65, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md:chunk:0036", "chunk_index": 36, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 942, "source_start_line": 923, "text_hash": "64eab5b3e6d02f32811611be662006789c5b05b3831ee55809f20167dd009eef", "token_count": 693, "visibility": "internal"}
@@ -0,0 +1,3 @@
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "544406cbb1fe005c727682f93a024de532e211a7f4865f0865f6bacb310ccb7c", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/bigscreen-viewer-token.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "Bigscreen Viewer Token 安全设计", "version": "08bcb565f85173dc6193e38bc6a154070673f473", "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "826cbf60fee98bb45c02d2bca21e855d23dbaaaece729099bc547871c41ead6e", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/business-data-chains/06-bigscreen-bff.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "LJWX Health 业务数据链 — 6. 大屏与运营 BFF 链路", "version": "08bcb565f85173dc6193e38bc6a154070673f473", "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "88ed27d713f4c4dda19b7d396c247077336c403e0faefbe10d1062e93ba7713f", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "多租户身份、权限、健康资源与多终端总体架构", "version": "08bcb565f85173dc6193e38bc6a154070673f473", "visibility": "internal"}
@@ -0,0 +1,89 @@
# Bigscreen Viewer Token 安全设计
**状态**:Accepted for implementation
**日期**:2026-07-19
**跟踪**:#176(设计)、#171(实现)、#172(切流)
**架构工作包**:IA-32 Viewer Principal
## 1. 目标与非目标
Viewer token 用于电视/kiosk 的无人值守只读大屏。它固定租户、可选组织、surface 与隐私模式,不得继承普通管理员权限。
非目标:不复用管理员长期 token,不允许告警处置/配置/导出,不通过 URL query 传播可重放 bearer token,也不替代正常 Admin 登录。
## 2. 载体与建立会话
采用“一次性 bootstrap code + HttpOnly cookie”组合:
1. 已认证且有签发权限的管理员创建一次性、短时 bootstrap code。
2. kiosk URL 仅在 fragment 中携带 code,例如 `/bigscreen/main#viewer_bootstrap=<opaque>`;fragment 不进入 HTTP request、access log 或 Referer。
3. 前端立即 `POST /api/bigscreen/viewer/session/exchange`,服务端原子消费 code,并设置 `Secure; HttpOnly; SameSite=Lax; Path=/api/bigscreen` cookie。
4. 前端用 `history.replaceState` 清除 fragment。后续 REST 和同源 EventSource 只依赖 cookie。
禁止把最终 viewer JWT 放入 query、localStorage 或可读 JavaScript cookie。bootstrap code 单次使用、默认 60 秒过期,数据库/Redis 只保存摘要。
## 3. Token 与 claims
Viewer token 使用独立签名 key ring,并带 `kid`。最低 claims:
| claim | 约束 |
|---|---|
| `iss` | 固定 LJWX issuer |
| `aud` | `ljwx-bigscreen-viewer` |
| `sub` | `viewer:<viewerId>` |
| `principal_type` | 固定 `VIEWER` |
| `tenant_id` | 必填且不可由请求参数扩展 |
| `org_ids` | 可选;请求范围必须与之求交集 |
| `surface` | 固定 `BIGSCREEN` |
| `privacy_mode` | `private`、`pseudonymized`、`aggregate-only` 之一;默认 `pseudonymized` |
| `actions` | 固定只读 allowlist |
| `iat/nbf/exp` | 必填;默认会话 8 小时,最大 24 小时 |
| `jti` | 必填且每次续期轮换 |
| `authz_version` | 用于租户/用户级立即收权 |
算法首选 Ed25519/ES256;如首期复用对称算法,必须使用与 Sa-Token/其他 JWT 完全隔离的高熵 secret,且保持相同 `kid` 轮换契约。
## 4. 权限边界
Viewer principal 只允许:snapshot、realtime、SSE、告警只读详情和必要的静态资源。
- 明确拒绝告警处置、派单人敏感列表、隐私模式修改、缓存重建、Admin/Phone/系统 API。
- BFF 的 query `tenantId/orgId` 只能缩小 token scope,不能扩权。
- 当前实现只启用 tenant-wide 或单组织 Viewer。单组织 token 的 snapshot/realtime/SSE replay/live 均强制使用 token org;聚合 SQL 和事件流同时裁剪,缺少可信 `orgId` 的事件 fail closed。多组织 token 在并集聚合与缓存契约完成前 fail closed。
- Viewer 与 Admin 凭据同时存在时,`/api/bigscreen/viewer/**` 按 viewer session 解析;普通 Admin API 永远不读取 viewer cookie。正常 Admin bearer/session 不被 viewer cookie 降权或覆盖。
- 所有拒绝记录 reason code、viewerId、tenantId、traceId;不得记录 token、bootstrap code 或完整 jti。
## 5. 撤销与缓存
- Redis 保存 `viewer:revoked:jti:<digest>`,TTL 等于 token 剩余寿命。
- Viewer 配置和 `authz_version` 变化可整体撤销该 viewer/tenant 的旧 token。
- 每次认证检查 token 签名、时间、audience、surface、viewer 状态、authzVersion 与 jti denylist。
- 允许最多 15 秒的本地只读缓存;撤销广播使多实例主动失效。Redis 不可用时 fail closed,不以可用性为由绕过撤销检查。
## 6. Key rotation
- 配置同时支持一个 active key 和至少一个 verify-only previous key。
- 新 token 只使用 active `kid`;旧 key 保留至少 `max token TTL + clock skew`。
- 轮换顺序:部署新 verify key → 切 active key → 观察 → 等待旧 token 过期 → 删除旧 key。
- 回滚只切回仍处于 verify window 的旧 active key,不恢复已撤销 jti。
## 7. 静默续期
`POST /api/bigscreen/viewer/session/renew` 仅在 token 剩余时间进入配置窗口后生效:
- 校验 viewer 仍启用、scope/隐私策略未放宽、authzVersion 未过期;
- 轮换 jti/cookie,并撤销旧 jti;
- 超出最大会话年龄、viewer 被禁用、scope 变化或 Redis 不可用时拒绝续期;
- UI 进入明确的 `session-expired`/`revoked`/`offline` 状态,不回退管理员 token或 mock 数据。
## 8. 审计、隐私与测试
审计事件覆盖 create/exchange/renew/revoke/use-denied/key-rotation,记录 actor、viewer、tenant、scope digest、结果、trace、时间和来源设备信息。日志守卫必须覆盖 JWT 与 bootstrap code。
最低测试矩阵:过期/未生效/错误 audience/kid、重复 exchange、撤销传播、跨租户/跨组织、请求体或 query anti-spoof、Admin API 拒绝、SSE cookie、续期竞争、key rotation、Redis 故障 fail-closed、日志无 token。
## 9. Feature flag 与回滚
- 默认 `ljwx.bigscreen.viewer.enabled=false`;关闭后停止签发/续期并拒绝 viewer session,正常 Admin 大屏继续工作。
- 回滚不删除 viewer 配置或审计;先关闭签发,再撤销现有 session,最后回退代码。
- #172 的 5225 兼容入口只能传 bootstrap code 或跳转到正常登录,不能重新引入 bearer token query。
@@ -0,0 +1,105 @@
# LJWX Health 业务数据链 — 6. 大屏与运营 BFF 链路
> business-data-chains 分册。章节编号沿用总目录(§ 前缀与原单文件一致),总览、当前实现摘要与跨链关系见 [README.md](README.md)。
**版本**: v1.9 · **最后更新**: 2026-07-19(冻结 V6 产品口径与 viewer token 安全设计)
---
![大屏与运营 BFF 链路](../diagrams/chain-6-bigscreen-bff.svg)
> 可维护源与 PNG 导出见 [diagrams/README.md](../diagrams/README.md)。
## 6. 大屏与运营 BFF 链路
> 多链路产物 → Boot BFF 聚合 → 快慢双通道 / SSE → ljwx-admin 大屏视图;旧 Flask 大屏只保留兼容语义。
**主契约速查**
| 类别 | 契约 |
|---|---|
| 快通道 | `GET /api/bigscreen/realtime`(事件/告警队列/设备状态变更) |
| 慢通道 | `GET /api/bigscreen/snapshot`(ETag/304 + 短 Cache-Control + Redis 热缓存 #243/#365) |
| 推送 | `GET /api/bigscreen/sse/subscribe` |
| 处置 | `POST /api/bigscreen/alert/{alertId}/action`(内部走 `AlertOperationService`) |
| 隐私 | `GET/PUT /api/bigscreen/privacy-mode`(持久化,§6.4) |
| 运维 | `POST /api/bigscreen/cache/rebuild`(清键+回填+对账) |
上游:健康链(§2)、告警链(§3)、设备链(§5)、消息链(§4)的聚合消费方。下游:ljwx-admin 大屏视图;旧 Flask 大屏仅兼容语义(部署现状见 §6.4)。
### 6.1 BFF 边界
Bigscreen V6 BFF 是跨健康、告警、消息、设备、组织、空间和运营数据的组合层。它不创建新的业务事实源,只做租户上下文内的读取、聚合、隐私降级和处置动作适配。
产品字段含义以 [Bigscreen V6 产品口径决策](../../05-features/bigscreen-v6-product-decisions.md) 为准。特别是兼容 key `onDutyHealthyRate` 当前表示“当日健康达标率”,不能在没有权威考勤事实时宣称真实在岗率。
| 通道 | 端点 | 节奏 | 输出 |
|---|---|---|---|
| 快通道 | `GET /api/bigscreen/realtime?tenantId=&eventCursor=&mode=` | 5s 轮询 / 可由 SSE 替代 | 告警队列、设备状态变化、新事件、cursor、风暴标志、通道状态 |
| 慢通道 | `GET /api/bigscreen/snapshot?tenantId=&window=&orgId=&buildingId=&mode=` | 30-60s;支持 ETag/304 | headline、kpis、spatial、trends、departments、devices、disposalEfficiency,实际 `BigscreenSnapshotVO` 还包含 employees、scoreDimensions、vitalsSummary、todayAlerts、exam、interventions、brandTitle/brandSubtitle/tenantLogoUrl(原文档未列全) |
| SSE | `GET /api/bigscreen/sse/subscribe` | 长连接 | 大屏事件推送,降低轮询延迟 |
| 处置动作 | `POST /api/bigscreen/alert/{alertId}/action` | 用户触发 | ack/close/assign 等动作,BFF 内部适配告警处理 |
| 告警详情 | `GET /api/bigscreen/alert/{alertId}/detail` | 用户触发 | 触发规则、处置时间线、上下文 |
| 派单人 | `GET /api/bigscreen/assignees` | 可缓存 60s | 可派单人 / 在岗人员 |
| 体检/干预 | `/exam/summary`、`/exam/urge`、`/interventions/summary` | 低频 | 年度体检、催办、干预计划聚合 |
### 6.2 数据来源
| 来源链路 | 事实表 / 服务 | BFF 消费方式 |
|---|---|---|
| 健康数据 | `t_user_health_data*`, `HealthWorkbenchService`, V2 health query | 趋势、健康分、部门态势、佩戴覆盖 |
| 告警 | `t_alert_info`, `t_alert_action_log`, `AlertRuleEngineService` | 告警队列、详情、处置时间线、风暴状态 |
| 设备 | `t_device_info`, `t_device_heartbeat`, `BigscreenDeviceStateChangeStore` | 在线/离线、低电量、未佩戴、设备状态变化事件 |
| 消息 | `t_device_message`, `t_device_message_detail`, `MessageAdminLedgerService` | 通知与响应漏斗,按需展示消息态势 |
| 组织/空间 | `sys_org_units`, track/geofence services, `BigscreenSpatialAggregator` | 部门聚合、空间热区、电子围栏态势 |
| 运营 | 体检记录、催办日志、干预计划 | 大屏运营看板模块 |
### 6.3 核心服务
| 服务 | 职责 |
|---|---|
| `BigscreenController` | REST BFF 入口,处理权限、ETag、参数和响应包装 |
| `BigscreenSseController` | SSE 订阅入口 |
| `BigscreenFacadeImpl` | BFF 编排层 |
| `BigscreenSnapshotAggregator` | 慢通道 snapshot 聚合 |
| `BigscreenSpatialAggregator` | 空间 / 楼栋 / 围栏聚合 |
| `BigscreenEventBuffer` / `BigscreenEventBroadcaster` | 事件缓冲、cursor、推送 |
| `BigscreenDeviceStateChangeStore` | 设备心跳/状态迁移事件暂存 |
| `BigscreenOnCallStore` | 在岗 / 派单人员辅助数据 |
| `BigscreenSnapshotCacheService` | #243 快照 Redis 热缓存:`bigscreen:snapshot:{tenantId}:{维度hash}`,TTL 30s,全路径 try/catch 降级直查 PG(Redis 只是加速层,事实源始终 PG);`POST /api/bigscreen/cache/rebuild` 清键 + 回填 + 对账(diffFields) |
**SSE 推送事件类型**(`BigscreenEventBroadcaster` 监听并转发,原文档未列):`BigscreenAlertCreatedEvent`、`BigscreenAlertActionEvent`、`BigscreenAlertRecoveredEvent`、`BigscreenDeviceStateChangedEvent`。
### 6.4 隐私与兼容
- 展示隐私模式 `private|pseudonymized|aggregate-only` 已持久化(#187/#366,V3.1.37 `sys_user.display_privacy_mode`,默认 `pseudonymized`):`GET/PUT /api/bigscreen/privacy-mode`(更新需 `bigscreen:privacy-mode:update` 权限,经 `OperationLogAspect` 落操作审计),`BigscreenDisplayPrivacyModeService` 读写偏好、`DisplayPrivacyFilter` 在 BFF 响应侧过滤敏感字段;跨租户大屏不得绕过 `TenantContext`。
- Viewer principal 的实现必须遵循 [Bigscreen Viewer Token 安全设计](../bigscreen-viewer-token.md):一次性 fragment bootstrap code 换取 HttpOnly cookie、固定 tenant/org/surface/privacy 只读 scope、jti 撤销、key rotation 与 fail-closed Redis 检查。最终 viewer bearer token 禁止进入 URL query、localStorage 或普通日志。
- 当前 BFF 只放行 tenant-wide 或单组织 Viewer:单组织 token 会强制覆盖 snapshot/realtime/SSE 的请求范围,所有业务聚合和 replay/live 事件按该组织裁剪;事件缺少可信 `orgId` 时 fail closed,多组织 token 在真正的并集聚合语义落地前拒绝访问。
- `/api/bigscreen/snapshot` 使用 ETag + 短 `Cache-Control`(`max-age=<snapshotCacheTtlSeconds>`,可配置,#365 起不再硬编码 30s),前端应处理 304。
- 旧 `BigscreenHealthApiV2Controller` 的 `/api/v2/messages/user`、`/api/v2/devices/user-info` 等端点只用于历史页面迁移。
- `ljwx-bigscreen` Flask 目录在仓库中仍物理存在。**已核实的是代码层面**:设备/告警/消息链路的当前主契约都已收敛到 ljwx-boot Bigscreen V6 BFF;**部署层面已核实(2026-07-09,ljwx-deploy)**:`apps/ljwx-health/base/deployment-bigscreen.yaml` + `service-bigscreen.yaml` 及 `overlays/ljwx-bigscreen/`(含 runtime-contract)仍在——Flask `ljwx-bigscreen` 仍作为独立 Deployment 随 ljwx-health 应用部署,尚未下线;切流/回滚与下线标准由 #172/#173 跟踪。
---
### 6.5 可观测性(2026-07-10 新增)
**已有**(#365 补齐):快照热缓存全套指标——`bigscreen.snapshot.cache.access` / `cache.write` / `cache.evict` / `cache.evicted.keys` / `cache.latency` / `build.latency`(`BigscreenSnapshotCacheService`,Micrometer)。
**缺失(如实记录)**:SSE/事件广播(`BigscreenEventBroadcaster`)与 BFF 聚合层(realtime/snapshot 组装)无独立指标;ETag/304 命中率无指标。触碰本链时携带补齐(暂未建单)。
### 6.6 已知问题与演进(2026-07-10 归拢)
大屏链开放 issue 全景(本节是唯一归拢入口,状态以 2026-07-10 核实为准):
**迁移主线**
- #166 总控 Epic:合并 admin / 独立布局 BFF / viewer token / 下线 Flask。
- #235 真实数据 Epic:已于 2026-07-19 完成最终验收并关闭。
**viewer token 链(按序推进)**:#176 mini design doc(本文已链接 Accepted 设计)→ #171 公开模式与安全模型 → #172 5225 端口兼容跳转/切流/回滚。
**运维验收链**:#170 Kiosk 健壮性与电视运行时恢复 · #177 Grafana 面板与告警 · #173 验收/观测/Flask 下线标准——Flask 目前仍在部署(§6.4)。
**其他**:#178 产品口径拍板(健康率/避损/3D/处置动作/KPI 配置)· #229 运动轨迹前后端方案(TrackService 占位端点)· #95 production SECRET_KEY 运行时契约(部署侧,单独处理)。
**近期已收口(2026-07-05 ~ 07-10,供追溯)**:#169 缓存语义与指标(#365)· #187 隐私模式持久化(#366)· #188/#190 排班与楼层空间管理(#367)· #179/#180 设备状态摄取与无效样本 gating(#368/#369)· #182 告警上下文快照(#370)· #181 平台告警触发器(#371)· #257 等前端 de-mock 系列。
@@ -0,0 +1,942 @@
# 多租户身份、权限、健康资源与多终端总体架构
**状态**: Accepted
**版本**: v1.17
**创建日期**: 2026-07-10
**评审日期**: 2026-07-10
**适用范围**: `ljwx-boot` / `ljwx-admin` / `ljwx-bigscreen` / `ljwx-phone` / `ljwx-watch` 及后续手环、无人机、机器人、第三方服务
**配套文档**: [业务数据链全景](./business-data-chains.md) / [设备抽象层](./device-abstraction-layer.md)
**实施跟踪**: [GitHub Epic #383](https://github.com/lingjingwanxiang/ljwx-health/issues/383)
---
## 1. 结论与关键决策
本设计补齐 `business-data-chains.md` 的横向架构:业务链文档回答“数据怎样流动”,本文回答“谁能以什么身份、在什么租户和组织范围内,对哪些资源做什么,以及不同终端怎样安全、高效地消费同一份业务能力”。
核心决策如下:
1. **角色、岗位、菜单三者解耦**:角色授予功能权限与数据范围;岗位表达人员业务属性和健康风险权重;菜单只做终端导航投影,不能成为后端授权依据。
2. **统一授权上下文**:每次请求只解析一次 `AccessContext`,统一给 Controller、Service、Mapper、缓存键和审计日志使用;客户端传入的 `customerId/userId/orgId` 只能是查询意图,不能成为可信身份。
3. **租户隔离、功能权限、数据权限、资源关系分层判定**:RLS 只解决租户边界,Sa-Token 权限只解决“能否执行动作”,组织/用户/设备/收件人关系由资源范围策略单独校验。
4. **组织权限按绑定点生效**:`sys_user_org_role` 中的角色只在对应组织及配置的子树内生效;登录时可以合并“功能权限集合”,但每次资源访问必须再次按目标组织收窄。
5. **岗位默认不授予权限**:岗位用于风险等级、健康评分、排班/工种等业务规则。确有“岗位自动带角色”的需求时,使用显式映射和审批,不在代码里把岗位名硬编码为权限。
6. **健康事实保存事件发生时归属**:健康数据、告警、消息投递保存 `customer_id + subject_user_id + org_id_at_event + device_id/device_sn + occurred_at`。人员调动不改写历史事实。
7. **授权看当前关系,报表看历史事实**:能否查看某个用户默认按当前组织关系判定;历史报表默认按事件发生时组织聚合。两个口径不得隐式混用。
8. **设备是独立主体,不是用户的另一种登录名**:手表/手环使用设备凭据,机器人/无人机使用设备或服务主体;绑定关系决定它代表哪个用户、组织或任务,`deviceSn` 本身不是密钥。
9. **领域 API 唯一,终端 BFF 负责裁剪**:Admin、Bigscreen、Phone、Watch 不复制健康/告警/消息规则,只消费统一领域服务;BFF 负责聚合、隐私、带宽、离线和交互差异。
10. **PostgreSQL 是事实源,Redis/大屏快照是可重建投影**:高频组织/用户查询通过索引、闭包表、聚合表和可重建缓存加速,不建立第二套不可对账的事实源。
---
## 2. 代码与本地数据库核查结论
本节记录 2026-07-10 的仓库和 `ljwx-local-postgres17/ljwx_health` 事实。目标架构不能把“已有表”误写成“已经完整接线”。
### 2.1 已有基础
| 能力 | 当前实现 | 结论 |
|---|---|---|
| 租户上下文 | `GlobalRequestInterceptor` 从 Sa-Token Session 初始化 `UserContext/TenantContext`,支持 CURRENT/TARGET/ALL | 可保留,但要让所有业务查询使用 effective tenant |
| 数据库隔离 | 主要用户、组织、健康、设备、告警、消息事实表已启用 RLS;应用层另有 MyBatis 租户拦截器 | 有双层基础,但覆盖并不完整,且本地连接用户 `ljwx` 是 `rolsuper=true, rolbypassrls=true`,本地普通请求不能证明 RLS 生效 |
| 组织模型 | `sys_org_units + sys_org_closure`,已有祖先/后代索引 | 适合组织树与管辖范围查询,无需另造树模型 |
| 组织管理范围 | `PrincipalBasedOrgManagerResolver` 已按 `sys_user_org.principal + sys_org_closure` 解析管理范围;`sys_org_manager_cache` 表、索引和 tenant isolation policy 已存在 | 解析器和可重建缓存可复用,但独立的 manager 事实关系尚未建立 |
| 基础 RBAC | `sys_role/sys_permission/sys_role_permission`,Sa-Token 从登录角色加载 permission resource | 功能权限主干可复用 |
| 管理边界 | `AuthorizationChain` 已包含 `AdminBoundaryGuard/OrgScopeGuard/RoleDelegateGuard` | 目前主要接在用户和组织写操作,尚未成为所有资源访问的统一入口 |
| 健康事实维度 | `t_user_health_data` 已有 `customer_id/org_id/user_id/device_sn/ts`,并有 tenant-user-time、tenant-org-time 索引 | 已具备高效查询的物理基础,需要明确 `org_id` 是事件快照 |
| 业务资源关联 | 设备、绑定、心跳、告警、消息及明细大多已有 customer/user/org/device 字段 | 字段存在但关系权威性和时间语义尚未统一 |
| 终端入口 | Admin workbench、Bigscreen V6 BFF、Mobile API、Watch API 均已存在 | 可按现有入口渐进收敛,不需要重建全部 API |
| 隐私呈现 | Bigscreen 已有 `private/pseudonymized/aggregate-only` | 可提升为跨终端统一字段策略 |
### 2.2 必须先解决的断点
| 断点 | 代码证据 | 风险 |
|---|---|---|
| 组织角色没有进入登录授权 | `SysRoleMapper.queryRoleListWithUserId()` 只 join `sys_user_role`;Session、菜单、权限都使用该结果;`sys_user_org_role` 仅有独立 CRUD | 组织角色“配得上、用不上”,或被迫重复成全局角色 |
| DataScope 没有形成强制查询入口 | `ScopeQueryFactory` 除自身外无业务调用;`DataScopeInterceptor.CUSTOM` 返回 `null`,而 `null` 同时表示 TENANT_ALL | 同租户跨部门、跨用户读取;CUSTOM 实际放大成全租户 |
| 菜单覆盖仅完成首段接线 | `AuthenticationFacadeImpl` 已把 `sys_menu_tenant_override` 的隐藏、别名、排序应用于当前登录人的 resolved assignment 菜单集合;menu-permission 一致性校验和版本缓存尚未完成 | 路由呈现已受租户覆盖约束,但缓存失效前仍不能把配置变更视为即时全实例生效 |
| 菜单与权限关系不完整 | 菜单来自 `sys_role_menu`,按钮/API 权限来自 `sys_role_permission`,两条路径独立 | 可能出现“有菜单无 API 权限”或“有 API 权限无入口”漂移 |
| 关系表租户隔离不一致 | `sys_user_role/sys_role_menu/sys_role_permission` 无 `customer_id`;本地 `sys_role_menu/sys_role_permission` 已 ENABLE + FORCE RLS 但没有 policy;`sys_user_role` 为 `relrowsecurity=false, relforcerowsecurity=true`,属于 FORCE 已设但尚未 ENABLE 的半配置态 | 受限数据库角色下可能拒绝全部;超级用户下又会掩盖问题;实施时不能把 `sys_user_role` 误判为“只差 policy” |
| 部分健康关系表未进 RLS | `t_device_heartbeat` 未启用 RLS,`R__rls_policies.sql` 也未包含 heartbeat 和 `t_device_message_department` | 设备状态或消息受众关系可能绕过统一租户边界 |
| `principal` 语义冲突 | 业务代码把 `sys_user_org.principal` 当组织负责人;数据库唯一索引却约束 `(user_id, customer_id) WHERE principal='1'`,名为“单一主部门” | 一个字段同时承担“主组织”和“负责人”,无法正确表达多组织管理 |
| 终端仍可传入越权维度 | Mobile `/health/data` 已改为 AccessContext 的 operator/effective tenant,并仅兼容与当前用户事实一致的 query hint;`/mobile/health/data/{id}` 与移动告警详情、AI 建议、处理入口已先按 effective tenant 定位持久化资源,再把 page/update 权限与资源当前 user 或 persisted assignee 求交,仅无 user 时才回退事件 org;Mobile 聚合状态、关键指标、风险告警和 AI 建议固定当前 tenant/user,反馈写入服务端解析的 tenant/user,缓存键包含 scope/privacy/authz version,普通员工 `COMMON` assignment 已从误配的 TENANT_ALL 收窄为 SELF;Bigscreen 的 realtime/snapshot/rebuildCache/examSummary/interventionSummary/SSE/alert action/detail/assignees 已按各自 permission 编译 subject scope,仅 TENANT_ALL 可调用租户聚合和管理入口且 query tenant 只能匹配 effective tenant;单组织 Viewer 的 snapshot/realtime/SSE replay/live 已固定 token org,健康、告警、设备、消息、体检、干预与空间聚合都在 SQL/事件层裁剪,未知事件 org fail closed,多组织 token 暂时拒绝;Viewer alert detail 已将 token org scope 与告警持久化 tenant/org 求交,Viewer 管理面也按 `bigscreen:viewer:manage` 要求 tenant-wide assignment scope;Health V2 score/profile 与 Alert V2 list/detail/handle/assign/status 已按 permission-specific scope 校验目标用户、当前组织、租户与持久化告警关系 | Mobile 健康列表/详情、告警资源及聚合状态/反馈首段,Bigscreen 租户聚合/SSE/Admin/单组织 Viewer、Health V2 score/profile、Alert V2 首段已 fail closed;多组织 Viewer 的真实聚合语义、其余 Health/Message/Device 入口仍需逐项校验同租户组织、用户与资源关系 |
| Watch 以 SN 代替认证 | `/watch/*` 为 `@SaIgnore`,仅凭 `X-Device-SN` 查绑定用户后注入上下文 | 知道或伪造 SN 即可冒充设备上传/收消息 |
| 当前组织与历史组织混用 | 一部分查询直接 join `sys_user.org_id`,另一部分读取事实表 `org_id` | 人员调动后历史报表漂移,不同页面统计口径不一致 |
| 领域模型有重复与映射漂移 | system/health 下并存多套同表 Entity;`TDeviceMessage` 对 `priority_level` 有重复属性映射 | 后续权限和资源字段扩展容易只改中一套模型 |
| 负向测试缺口 | 现有测试覆盖租户切换、事件租户隔离和大屏隐私,但未系统覆盖“同租户跨组织/跨用户/伪造设备 SN” | 安全回归难以及时发现 |
### 2.3 当前索引结论
已经适合保留的索引:
- `t_user_health_data(customer_id, user_id, ts DESC)`
- `t_user_health_data(customer_id, org_id, ts DESC)`
- `sys_org_closure(ancestor_id, customer_id)`、`(descendant_id, customer_id)` 和唯一闭包关系
- `sys_user_org(customer_id, user_id, org_id, is_deleted)`
- `sys_org_units(customer_id, parent_id)`、`(customer_id, status, is_deleted)`
明显缺失或不足的索引:
- `sys_role_menu(role_id, menu_id)`、`sys_role_permission(role_id, permission_id)`、`sys_user_role(customer_id, user_id, role_id)` 的租户化唯一索引
- `sys_user_position(customer_id, user_id, position_id)` 唯一索引;当前名为 customer 的索引实际只有 `user_id`
- `t_device_info(customer_id, user_id)`、`t_device_info(customer_id, org_id, online_status, last_report_time DESC)`
- `t_device_heartbeat(customer_id, user_id, last_heartbeat_at DESC)` 以及当前设备唯一约束
- `t_alert_info(customer_id, user_id, occur_at DESC)`、`t_alert_info(customer_id, org_id, alert_status, occur_at DESC)`
- `t_device_message(customer_id, user_id, sent_time DESC)`
- `t_device_message_detail(customer_id, user_id, response_status, sent_time DESC)`
- `t_device_message_department(customer_id, department_id, message_id)`
索引必须由真实查询计划决定。上述列表是首批候选,不应不经 `EXPLAIN (ANALYZE, BUFFERS)` 就全部上线。
---
## 3. 目标架构
```mermaid
flowchart TB
subgraph Surface[终端与交互面]
ADMIN[Admin]
BIG[Bigscreen]
PHONE[Phone]
WEAR[Watch / Band]
EDGE[Drone / Robot / Gateway]
end
subgraph BFF[Surface BFF / API Adapter]
NAV[Navigation + Capability API]
ABFF[Admin BFF]
BBFF[Bigscreen BFF]
MBFF[Mobile BFF]
DBFF[Device Ingress / Command API]
end
subgraph Access[身份与策略平面]
AUTHN[Human / Device / Service Authentication]
CTX[AccessContext Resolver]
PDP[Policy Decision: tenant + permission + data scope + relation + privacy]
AUDIT[Decision Audit]
end
subgraph Domain[领域服务]
USER[User / Organization / Position]
DEVICE[Device Registry / Binding]
HEALTH[Health Query / Ingest]
ALERT[Alert Center]
MESSAGE[Message Center]
end
subgraph Data[事实与投影]
PG[(PostgreSQL / TimescaleDB facts)]
OUTBOX[(Outbox)]
SPRING[Required Spring Event publisher]
KAFKA[Kafka publisher / transport]
CH[(ClickHouse projection)]
READ[(Org/User read models)]
REDIS[(Redis rebuildable cache)]
end
Surface --> BFF
BFF --> AUTHN --> CTX --> PDP
PDP --> Domain
PDP --> AUDIT
Domain --> PG
Domain --> OUTBOX --> SPRING --> READ --> REDIS
OUTBOX -. shadow / promoted .-> KAFKA --> CH
KAFKA -. durable projection after promotion .-> READ
PG -. rebuild / reconcile .-> READ
PG -. PG↔CH reconcile .-> CH
BFF --> READ
BFF --> REDIS
```
### 3.1 分层职责
| 层 | 只负责 | 不负责 |
|---|---|---|
| 认证 AuthN | 证明人、设备、服务是谁 | 决定能看哪些业务数据 |
| 租户隔离 | 限制 effective customer | 代替组织/用户数据权限 |
| 功能权限 RBAC | 判断 action/resource permission | 从菜单可见性推导权限 |
| 数据范围 | 计算可访问组织和用户集合 | 跨租户放行 |
| 资源关系 | 校验本人、绑定设备、告警受理人、消息收件人等关系 | 替代 RBAC |
| 隐私策略 | 字段脱敏、聚合阈值、用途限制 | 改变事实数据 |
| BFF | 聚合、裁剪、协议适配、缓存策略 | 重写领域规则 |
| Read Model | 加速确定口径的查询 | 成为不可重建事实源 |
### 3.2 事件源与投影边界
`t_health_event_outbox` 是领域事务提交后的**唯一事件源**。现有 `HealthEventOutboxWorker` 读取同一条 outbox 记录,再交给 publisher adapter:
- `SpringEventHealthEventPublisher` 是当前 required 的进程内运行链;
- `KafkaHealthEventPublisher` 是当前 non-required 的 shadow publish,复用同一事件标识与 payload;
- Kafka 是传输通道,ClickHouse、组织/用户读模型和 Redis 都只是可重建投影;PostgreSQL 事实仍是对账基准。
禁止领域服务在“写 PG 后”再独立直发另一条 Kafka 事件,也禁止 Spring Event 与 Kafka 各自定义无法关联的事件 ID。进程内低延迟投影可以消费 required Spring Event;跨进程投影在 Kafka 完成 shadow 对账、重放和延迟门禁后再切为 durable consumer。每个消费者必须以 outbox event id 幂等,保存消费位点/版本,并提供 PG rebuild、diff 和 lag 指标。现有 Bigscreen Redis 热缓存从 PG 或 canonical read model 重建,不把未经晋级的 Kafka shadow 数据当成第二事实源。
---
## 4. 身份、组织、岗位、角色和菜单模型
### 4.1 主体模型
统一使用 `Principal` 表达调用主体:
| principal_type | 示例 | 认证方式 | 默认数据边界 |
|---|---|---|---|
| `HUMAN` | 平台管理员、租户管理员、部门负责人、员工 | Sa-Token/OIDC,后续可接 MFA | 角色 + 组织范围 + 资源关系 |
| `DEVICE` | Watch、手环、机器人、无人机 | 设备注册凭据、签名 nonce、短期 token 或 mTLS | 设备自身 + 当前有效绑定/任务 |
| `SERVICE` | 定时任务、第三方告警源、内部 worker | workload identity/API key/mTLS | 明确 service scope,不模拟普通用户 |
| `VIEWER` | 无人值守大屏 | 短期 viewer token | 固定租户/组织 + 固定 surface + 隐私模式 |
设备和服务主体不能伪造 `UserContext` 后获得完整用户权限。需要代表用户执行时,必须保留:
```text
actor_principal = device/service
subject_user_id = 被代表的用户
delegation_source = binding/task/grant
```
### 4.2 组织与人员关系
推荐语义:
```text
sys_user [已有] 人的租户内账号
sys_org_units [已有] 组织树
sys_user_org [已有,待修正语义] 人员组织任职关系
- is_primary 当前主组织,只能一个
- valid_from / valid_to 关系有效期
sys_org_manager [新建] 组织管理责任,不再复用 is_primary/principal
- customer_id, org_id, user_id
- manager_type, valid_from, valid_to
sys_org_closure [已有] 组织祖先/后代投影
sys_org_manager_cache [已有] 管理范围缓存,可重建
```
当前 `PrincipalBasedOrgManagerResolver` 已能基于 `sys_user_org.principal` 和 closure 计算管理范围,并读取既有 `sys_org_manager_cache`。迁移时保留该解析器作为兼容适配层:先引入新的 `sys_org_manager` 事实表,再让 resolver/cache 改从新事实重建,最后停止复用 `principal`。
迁移期间可以保留 `sys_user.org_id/org_name` 作为当前主组织读优化字段,但它们是 `sys_user_org.is_primary=true` 的投影,不是关系事实源。
### 4.3 岗位语义
岗位回答“这个人在业务上做什么”,角色回答“这个账号在系统里能做什么”。
岗位可承载:
- 工种/职级/作业场景;
- `risk_level/weight` 等健康风险参数;
- 默认采集策略、体检计划、健康阈值模板;
- 排班、在岗、暴露因素等后续业务属性。
岗位不能直接承载:
- 菜单列表;
- API permission 字符串;
- 跨组织数据范围;
- 平台或租户管理员级别。
如果一个用户在不同组织有不同岗位,目标关系应包含 `org_id` 和有效期。`sys_user_position` 已有 `customer_id` 物理列,首期应确保它始终正确写入,再补 `org_id/valid_from/valid_to` 和 tenant-aware 唯一索引;后续再评估是否收敛成统一 assignment 表。
### 4.4 角色与组织绑定
角色分两种绑定:
```text
sys_user_role
-> 租户级或 SELF 角色
-> 在整个租户或本人范围生效
sys_user_org_role
-> 组织绑定角色
-> 只在绑定 org_id 或其子树内生效
```
登录授权快照不能只是一组 roleId,应保存带作用域的 assignment:
```json
{
"authzVersion": 42,
"roleAssignments": [
{"assignmentId": 1001, "roleId": 10, "roleDataScope": "SELF", "boundary": "TENANT"},
{"assignmentId": 1002, "roleId": 21, "roleDataScope": "DEPT_AND_CHILD", "boundary": "ORG_SUBTREE", "anchorOrgId": 3001}
],
"permissions": ["health:record:read", "alert:handle"],
"scopeDigest": "sha256:..."
}
```
`permissions` 可以为前端动作提示做并集;后端处理具体 `action + targetOrgId/subjectUserId` 时必须用原始 assignment 再判定,不能只看并集,也不能把 assignment A 的 permission 与 assignment B 的范围拼成一次授权。
### 4.5 菜单与能力投影
菜单生成公式:
```text
EffectiveNavigation
= PlatformBaselineMenu
∩ TenantEntitlement
∩ RoleMenuGrant
∩ PermissionAvailability
∩ SurfaceCapability
⊖ TenantOverride.hidden
+ TenantOverride.alias/sort
```
后端授权只检查 permission,不检查菜单。建议提供:
- `GET /api/auth/capabilities?surface=admin|bigscreen|phone|watch`
- `GET /api/auth/navigation?surface=admin`
- 返回 `authzVersion/navigationVersion/tenantConfigVersion`,支持客户端和 Redis 精确失效。
Phone、Watch 不必复刻 Admin 菜单树;它们消费 capability 列表和固定的原生导航。未来若需要租户可配置终端模块,再引入 `sys_surface_feature`,不要把 Admin Vue route 直接下发给设备。
### 4.6 多租户资源生命周期
沿用现有“三层资源模型”,但为模板升级增加可治理的版本语义:
| 资源 | 所有权与策略 | 更新方式 |
|---|---|---|
| 菜单、permission | 平台基线,租户只读;受 license/entitlement 约束 | 平台发布;租户仅通过 override 做隐藏、别名、排序 |
| 角色模板 | 平台 blueprint,创建租户时复制 | 租户副本可编辑;保留 `source_template_id/template_version` |
| 岗位模板 | 平台 blueprint,租户复制后可按组织调整风险参数 | 同角色模板,禁止平台更新静默覆盖租户修改 |
| 租户角色/岗位 | 租户自治 | 只在本租户可见,受管理员边界和委派池限制 |
| 组织、用户、assignment | 租户事实 | 不从模板继承 |
模板升级流程必须是 `diff -> preview -> apply -> audit -> rollback`:
```text
TenantBlueprint(version)
-> compare source_template_id + last_synced_version
-> classify unchanged / tenant-modified / removed / new
-> auto-apply only unchanged items
-> require tenant decision for conflicts
```
新增 permission 可以通过 entitlement 灰度开放;删除或改名必须先检查角色、菜单、终端 capability 和 API 注解引用。模板同步不能直接复用“创建租户时无条件复制”的逻辑。
---
## 5. 统一授权模型
### 5.1 AccessContext
```text
AccessContext
actorPrincipalId/type
operatorUserId # HUMAN 或被授权的操作用户
loginCustomerId
tenantScope # CURRENT / TARGET / ALL
effectiveCustomerId
surface # admin / bigscreen / phone / watch / edge
grantAssignments[] # 权限与范围不可拆开的原始授权单元
assignmentId / roleId
permissionRefs[]
roleDataScope # SELF / DEPT_ONLY / DEPT_AND_CHILD / TENANT_ALL / CUSTOM
boundary # TENANT / ORG_SUBTREE
anchorOrgId / customScopeId
validFrom / validTo
permissionsHint[] # 仅供能力提示的并集,不是后端授权依据
scopeDigest # assignments 的稳定摘要,用于缓存键与审计
deviceId / bindingId
privacyMode
authzVersion # platform/tenant/user version tuple
traceId
```
所有字段由服务端 Session、设备凭据、绑定关系和租户配置解析。Header `X-Client-Platform/X-Device-Vendor/X-Source-Adapter` 只参与遥测和能力协商。`AccessContext` 不保存一个扁平的最终 `dataScope`:多组织 assignment 的范围只能结合当前 action 和目标资源求值。
### 5.2 判定公式
```text
ALLOW = authenticated(actor)
AND tenantVisible(effectiveTenant, resource.customerId)
AND existsGrantAssignment(actor, action, resource)
AND relationAllows(actor, resource, action)
AND surfaceAllows(surface, action)
AND privacyAllows(purpose, fields)
AND notBreakAdminBoundary(actor, target)
```
建议新增统一入口:
```java
accessPolicy.authorize(Action.READ_HEALTH_RECORD, ResourceRef.health(recordId));
scopedQuery.healthRecords(query, accessContext);
```
Controller 的 `@SaCheckPermission` 继续作为第一道功能权限 PEP;Service 层的 `AccessPolicy` 是不可绕过的资源级 PEP;数据库 RLS 是最后的租户边界。账号禁用、租户冻结、管理员边界、隐私禁止和显式 deny 属于 hard deny,优先于任何 grant assignment。
### 5.3 数据范围编译
| DataScope | 查询谓词 |
|---|---|
| SELF | `subject_user_id = actorUserId` |
| DEPT_ONLY | `org_id IN (角色绑定组织)` |
| DEPT_AND_CHILD | `EXISTS sys_org_closure WHERE ancestor_id IN (managedRoots) AND descendant_id = resource.org_id` |
| TENANT_ALL | 仅 `customer_id = effectiveCustomerId` |
| CUSTOM | join `sys_data_scope_member`;没有成员时返回空集,绝不能退化为 TENANT_ALL |
每次授权按目标资源编译,而不是先把多个 assignment 压成“最宽枚举”:
1. 只选择当前有效且自身授予目标 permission 的 assignments;
2. 将角色数据范围与 assignment 边界取交集:`effectiveScope = roleDataScope ∩ boundary`;除 `SELF/TENANT_ALL` 外必须有明确 anchor,租户级部门角色按当前有效组织展开成独立 assignments,不能隐式共用一组 managed roots;
3. 每个 assignment 独立判断目标 `orgId/subjectUserId`,任一完整 assignment 覆盖目标即可放行,即同一 action 的 grant 范围取并集;
4. `sys_user_org_role` 的最大边界始终是绑定组织/子树,即使其角色声明 `TENANT_ALL` 也不能越出该边界;
5. `CUSTOM` 成员也必须与 assignment 边界取交集;不能把一个 assignment 的 permission 与另一个 assignment 的 scope 交叉拼接。
因此,一个用户在租户级只有 `SELF`,同时在组织 A 持有 `DEPT_AND_CHILD + health:record:read` 时:本人资源由前一个 assignment 放行;组织 A 子树资源由后一个 assignment 放行;组织 B 仍拒绝。`permissionsHint` 可以显示该用户具备 read 动作,但不能证明任意目标资源可读。
小组织可以把 org IDs 缓存在授权快照中;大组织直接用 closure `EXISTS/JOIN`,避免生成数千项 `IN (...)`。
建议增加:
```text
sys_data_scope
id, customer_id, name, status, version
sys_data_scope_member
scope_id, member_type(ORG/USER), member_id, include_children
```
### 5.4 当前关系与历史归属
同一查询必须显式选择两种口径之一:
| 口径 | 用途 | 组织来源 |
|---|---|---|
| `CURRENT_SUBJECT_SCOPE` | 判断管理员现在能否查看某用户 | 当前有效 `sys_user_org` + closure |
| `EVENT_ORG_SCOPE` | 历史趋势、部门绩效、事件审计 | 事实表 `org_id_at_event` |
默认规则:先用当前关系完成授权,再按请求的报表口径聚合。用户离开组织后,原部门管理员是否还能看历史个人明细属于隐私策略,不通过改写历史数据实现。
### 5.5 缓存与失效
版本事实由 PostgreSQL 持有,不由某个应用实例或 Redis 自行发号。目标表可以采用:
```text
sys_platform_authz_version(id=1, authz_version, updated_at)
sys_tenant_authz_version(customer_id, authz_version, navigation_version, blueprint_version, updated_at)
sys_user_authz_version(customer_id, user_id, authz_version, updated_at)
```
对用户生效的 `authzVersion` 是 `(platformVersion, tenantVersion, userVersion)` 元组或其稳定摘要;navigation 再加入 `navigationVersion`。授权缓存键必须包含:
```text
authz:{customerId}:{userId}:{platformVersion}:{tenantVersion}:{userVersion}
navigation:{customerId}:{userId}:{surface}:{platformVersion}:{tenantVersion}:{userVersion}:{navigationVersion}
scope:{customerId}:{userId}:{scopeDigest}:{tenantVersion}:{userVersion}
```
只有 `AuthorizationVersionService` 可以递增版本;它与授权事实变更在同一数据库事务内执行原子 `UPDATE ... version = version + 1 RETURNING version`,并写入 authorization outbox。递增归属如下:
- 用户调动、用户角色/组织角色、设备绑定和账号状态等单用户变化递增 user version;
- 角色定义、permission、菜单、租户 override、组织结构等影响一批用户的变化递增 tenant version,菜单类同时递增 navigation version;
- 平台 permission/menu 基线和全局策略变化递增 platform version;
- blueprint 发布递增 blueprint version,只有实际应用到租户时才同步递增该 tenant 的授权/navigation 版本。
提交后事件负责删除 Redis/L1 快照并广播新版本;Redis 只镜像最新版本,miss 时回源 PostgreSQL,多实例不得依赖本机内存计数器。订阅丢失由短 TTL + DB 回源兜底。账号禁用、角色移除、设备凭据撤销等高风险收权必须在提交后同步更新共享版本指针、撤销 Session/token;高风险请求在版本广播不可用或版本不确定时直接回源 PostgreSQL 校验,不能等待缓存 TTL,也不能继续使用旧快照。版本递增失败则授权事实事务整体回滚。
---
## 6. 健康业务资源归属模型
### 6.1 通用资源信封
所有健康领域事实和事件采用统一逻辑字段;物理列可阶段性沿用现名:
```text
resource_id
customer_id
subject_type # USER / DEVICE / ORG / MISSION / EXTERNAL
subject_user_id # 当前多数表物理列为 user_id
org_id_at_event # 当前多数表物理列为 org_id
position_id_at_event # 需要按事件时岗位解释风险时保存
device_id / device_sn
source_resource_type/id
occurred_at
ingested_at
actor_principal_id/type
trace_id
schema_version
```
### 6.2 资源关系
```mermaid
erDiagram
TENANT ||--o{ ORG : owns
TENANT ||--o{ USER : owns
ORG ||--o{ USER_ORG : contains
USER ||--o{ USER_ORG : assigned
USER ||--o{ USER_POSITION : works_as
ROLE ||--o{ USER_ROLE_ASSIGNMENT : grants
USER ||--o{ USER_ROLE_ASSIGNMENT : receives
ORG ||--o{ USER_ROLE_ASSIGNMENT : scopes
DEVICE ||--o{ DEVICE_BINDING : has
USER ||--o{ DEVICE_BINDING : bound_to
DEVICE_BINDING ||--o{ HEALTH_OBSERVATION : produces_for
HEALTH_OBSERVATION ||--o{ ALERT : triggers
ALERT ||--o{ MESSAGE : publishes
MESSAGE ||--o{ MESSAGE_DELIVERY : fans_out
USER ||--o{ MESSAGE_DELIVERY : receives
```
### 6.3 各资源的权威关系
| 资源 | 权威归属 | 当前可复用字段 | 必须收敛的规则 |
|---|---|---|---|
| 设备注册 | tenant + device | `t_device_info.customer_id/serial_number` | `(customer_id, serial_number)` 唯一;user/org 只是当前绑定投影 |
| 设备绑定 | tenant + device + user + org + validity | `t_device_user` | `BOUND` 的时间区间唯一;解绑保留历史,不硬删除 |
| 心跳 | tenant + device + current binding | `t_device_heartbeat.customer_id/user_id/device_sn` | 服务端从凭据和绑定注入,不能信任 body userId |
| 健康数据 | tenant + subject user + event org + device + time | `t_user_health_data` 已具备主要字段 | `org_id` 明确为事件快照;绑定无效时拒绝或进入隔离队列 |
| 告警 | tenant + subject + event org + source resource | `t_alert_info.user_id/org_id/device_sn/health_id/source_*` | source 必须可追溯;查询按 subject 与 handler 两套关系区分 |
| 消息主记录 | tenant + publisher + audience snapshot | `t_device_message.audience/target_scope/source_event_id` | 群组消息不能把主表单个 user/org 当唯一受众事实 |
| 消息投递 | tenant + recipient user + org-at-publish + channel/client | `t_device_message_detail` | 已读/确认只能由收件人或授权操作员变更 |
### 6.4 设备绑定的时间语义
推荐的绑定约束:
```text
同一 tenant + device 在任一时刻最多一个 ACTIVE/BOUND 用户
同一用户可以同时绑定多个设备
每个用户可以有一个 primary device,但 primary 不是唯一设备
解绑只关闭 valid_to,不删除历史
重新绑定必须生成新 binding_id
健康上传按 occurred_at 匹配当时有效绑定,至少按接收时绑定做严格校验
```
`sys_user.device_sn`、`t_device_info.user_id/org_id` 可保留为当前绑定投影,禁止继续作为唯一事实源。
### 6.5 设备初始信任与凭据生命周期
“绑定审批通过”与“设备已经证明自己是谁”是两件事。现有 `t_device_bind_approval` 和 `DeviceBindApprovalServiceImpl` 作为 provisioning 的业务授权锚点,但审批记录本身不是设备凭据:
1. 设备先使用出厂预置公钥/证书、一次性 QR 激活码或线下安全下发的 bootstrap secret 证明其库存身份;只保存 public key、credential id 或 secret hash,不保存明文长期密钥;
2. HUMAN 用户发起绑定请求,审批流确认 `customer + device + user + org`,审批通过后才允许 provisioning;
3. 设备完成 possession challenge 后领取短期 token,或注册设备生成的公钥并取得客户端证书;bootstrap 凭据随即标记 consumed,不能重放;
4. token/证书中的 tenant、device、binding、capability 来自服务端事实;解绑、遗失、租户冻结或审批撤销时独立轮换/撤销;
5. 仅凭 `deviceSn` 的旧设备进入有期限、有审计的 allowlist 兼容通道,不能自动完成首次注册。
短期 token、请求签名、nonce/replay 与 `/watch/**` 的 `disabled -> monitor -> enforce`
迁移契约见
[`device-request-authentication.md`](../06-development/backend/device/device-request-authentication.md)。
该实现只闭合 bootstrap proof 之后的请求认证链;生产 KMS/Vault 凭据供应和
Phone/Watch 安全存储仍是切换 `enforce` 的前置条件。
### 6.6 非用户设备
机器人、无人机等资源不应强制绑定 `user_id`:
- 设备可直接归属 tenant/org;
- 一次任务通过 `mission_id/operator_user_id` 关联人员;
- 设备遥测进入通用 telemetry/observation,再按能力映射到健康、环境或安全事件;
- 下行指令要求独立 `device:command:*` 权限、双向认证、幂等 commandId 和审计回执;
- 不把机器人/无人机的控制能力放进普通 Phone token。
---
## 7. 高效组织与用户查询架构
### 7.1 查询入口
新增统一 `SubjectScopeQueryService`,禁止各 Controller 自行拼 `customerId/orgId/userId`:
```text
resolve AccessContext
-> validate requested user/org belongs to effective tenant
-> intersect requested scope with allowed scope
-> produce AuthorizedSubjectScope
-> domain query uses the authorized scope
```
```text
AuthorizedSubjectScope
customerId
mode = SELF | USER_SET | ORG_SUBTREE | TENANT
userIds? # 小集合才物化
rootOrgIds?
includeChildren
grouping = EVENT_ORG | CURRENT_ORG
```
### 7.2 查询模式
| 场景 | 推荐 SQL 形状 | 说明 |
|---|---|---|
| 用户个人时序 | `customer_id + user_id + ts range` | 命中现有 user-time 索引 |
| 单组织历史趋势 | `customer_id + org_id_at_event + ts range` | 命中现有 org-time 索引 |
| 组织子树当前人员 | closure → `sys_user_org` → user | 不依赖逗号 ancestors 模糊匹配 |
| 组织子树历史事实 | closure 得到 descendant org,join fact.org_id_at_event | 可使用 semi-join,避免先拉全量 userId |
| 当前用户归属聚合 | fact.user_id join 当前 primary membership | 必须在 API 中标记 current-org 口径 |
| 告警工作队列 | tenant + org/status/severity + occur_at keyset | 避免 offset 扫描深页 |
| 用户收件箱 | tenant + recipient user + sent_time/response_status | 主记录与 delivery 明细分层查询 |
| 大屏 | 组织/租户分钟级投影 + realtime event cursor | PG 可重建,Redis 只加速 |
### 7.3 读模型
建议按用途建设窄投影,不建设一个全能宽表:
| 投影 | 主键 | 消费端 |
|---|---|---|
| `rm_user_health_current` | customer + user | Phone、Admin 用户概览 |
| `rm_org_health_hourly/daily` | customer + org + bucket | Admin、Bigscreen 趋势 |
| `rm_device_current_state` | customer + device | Admin、Bigscreen、Phone |
| `rm_alert_workload` | customer + org + status + severity | Admin、Bigscreen |
| `rm_message_inbox` | customer + user + message | Phone、Watch |
| `rm_bigscreen_snapshot` | customer + filter signature | Bigscreen |
投影统一消费 §3.2 定义的 outbox 事件,不再建立第二条领域事件链:
```text
domain transaction
-> PostgreSQL facts + t_health_event_outbox
-> HealthEventOutboxWorker
-> required Spring Event publisher -> 当前进程内消费者/低延迟投影
-> non-required Kafka shadow publisher -> ClickHouse 与对账
-> Kafka 晋级后 -> 跨进程 durable read-model consumers
```
Kafka 晋级前,shadow 失败不能改变 outbox 已完成语义,跨进程读模型也不能假设 Kafka 是完整事实源;晋级门禁至少包含事件 ID 一致、PG↔ClickHouse 数量/字段对账、重放幂等、消费延迟和故障恢复。每张投影必须有重建任务、对账 SQL、延迟指标、版本字段、source outbox event id 和 PG fallback。现有 Bigscreen Redis 热缓存继续作为从 PG/canonical snapshot 重建的末级缓存。
### 7.4 分页、缓存和隐私
- 时序、告警、消息使用 `(time, id)` keyset pagination;管理端固定页码场景可以保留浅层 offset。
- 缓存键必须包含 tenant、授权范围摘要、隐私模式和查询版本。
- 不缓存未经脱敏的 Bigscreen 响应到 aggregate-only/pseudonymized key。
- 人数低于租户配置的最小聚合阈值时,不返回组织健康统计明细,防止小样本反推个人。
---
## 8. 多终端呈现模型
### 8.1 终端矩阵
| 终端 | 主体 | 默认范围 | 主要能力 | 输出策略 |
|---|---|---|---|---|
| Admin | HUMAN | 角色绑定的 tenant/org/user | 配置、用户管理、健康工作台、告警处置、消息运营、设备管理 | 私有字段按 permission + purpose 控制 |
| Bigscreen | VIEWER/HUMAN | 固定 tenant,可选受限 org | 汇总、实时态势、有限告警处置 | 默认 pseudonymized;aggregate-only 可无人值守 |
| Phone | HUMAN | SELF | 个人健康、本人设备、本人消息/告警;管理者显式切换 managed scope | 不接受任意 user/customer 覆盖当前身份 |
| Watch/手环 | DEVICE | 当前 binding subject | 上传、配置拉取、少量消息、回执 | 低带宽 DTO、短 token、无自由组织查询 |
| 无人机/机器人 | DEVICE/SERVICE | tenant/org/mission | 遥测、任务、受控下行命令 | capability allowlist + 强审计 |
| 第三方系统 | SERVICE | grant 中声明 | 事件推送、批量查询、Webhook | API scope、限流、签名、字段最小化 |
### 8.2 Surface Capability
每个终端先获取能力,再呈现 UI:
```json
{
"surface": "phone",
"authzVersion": 42,
"features": {
"health.self.read": true,
"health.org.read": false,
"alert.self.ack": true,
"alert.reassign": false,
"device.bind.request": true
},
"privacyMode": "private",
"limits": {"historyDays": 90, "pageSize": 50}
}
```
能力由租户授权、角色权限、组织 assignment、终端类型、设备能力和 license 共同计算。前端隐藏功能只改善体验,后端仍必须鉴权。
### 8.3 BFF 收敛
```text
Domain Services
HealthQueryService
DeviceRegistryService
AlertCenterService
MessageCenterService
|
+-- AdminHealthWorkbenchController
+-- BigscreenController/SSE
+-- MobileApiController
+-- DeviceIngressController
```
收敛规则:
- Admin workbench 与 Health V2 共用 `AuthorizedSubjectScope`;
- Bigscreen 的 `tenantId/orgId` 必须与 viewer/session scope 取交集;
- Phone 默认强制当前 userId,只有拥有 manager capability 时才能传 managed userId;
- Watch/手环由设备凭据和 binding 注入 customer/user/org,body 中同名字段一律覆盖或拒绝;
- 所有端的告警动作调用同一状态机,消息响应调用同一响应服务。
---
## 9. 数据库与本地 SQL 方案
### 9.1 P0 关系表修正
建议为以下关系表补 `customer_id NOT NULL` 并回填:
- `sys_user_role`
- `sys_role_menu`
- `sys_role_permission`
理由:让 RLS、唯一约束、缓存失效、租户清理和查询索引使用同一租户键,不再依赖多层 join 才能证明归属。
同时:
- `sys_user_org` 增加独立 `is_primary`,迁移后停止用 `principal` 表示主组织;
- 新建 `sys_org_manager`,或至少把 `principal` 明确定义为 manager 并修正唯一约束;
- `sys_user_position` 保证现有 `customer_id` 正确写入,补 `org_id`、有效期和真实 tenant-aware 索引/唯一约束;
- `t_device_heartbeat/t_device_message_department` 纳入一致的 RLS 和受限角色测试;
- 资源关系表增加 tenant-aware foreign key/校验触发器或 service guard;
- RLS policy 使用 `NULLIF(current_setting(...), '')` 和显式 elevation,所有策略由受限角色回归测试。
### 9.2 RLS 会话变量与连接池纪律
当前 `TenantAwareDataSource` 在连接借出时使用 `set_config('app.current_tenant', value, false)`,这是 **session-scoped**,并在代理 `Connection.close()` 时清空。它能作为迁移基础,但连接池安全必须满足以下硬约束:
- `SET LOCAL` 只用于已经进入显式事务的业务路径;不能在 Hikari checkout 的 auto-commit 连接上盲目替换为 `SET LOCAL`,否则语句结束后上下文立即失效;
- 非事务或尚未完成事务化的路径继续使用 session-scoped set 时,连接归还前必须 reset;reset 失败的物理连接必须 evict/关闭,禁止只记录 warning 后回池;
- tenant A、TARGET/ALL、tenant B 每次 checkout 都显式覆盖上下文,不能继承连接上一次的值;elevation 还必须独立校验受控角色和审计原因;
- 异常、rollback、timeout、异步执行、嵌套事务和只读事务均进入 reset/evict 测试;不得把 tenant ThreadLocal 自动传播等同于数据库 session 已正确设置;
- 用同一物理连接按 `tenant A -> exception -> tenant B`、`CURRENT -> ALL -> CURRENT` 顺序做回归,并以 `NOBYPASSRLS` 用户验证查询结果和 `current_setting`。
目标态优先让需要 RLS 的访问进入明确事务,在事务开始后执行 `SET LOCAL app.current_tenant = ...`;仍需 auto-commit 的少数入口保留严格的 set/reset-on-return 封装。两种模式都必须 fail closed:上下文缺失、非法或清理失败时不能继续执行下一租户请求。
### 9.3 本地执行约束
本仓库本地开发不依赖 Flyway:
1. 可复用、幂等 SQL 放到 `ljwx-boot/scripts/sql/`;
2. 通过 `ljwx-boot/scripts/apply-local-sql.sh` 或 `docker exec ... psql` 应用;
3. 每个结构变更提供 precheck、apply、verify、rollback/兼容说明;
4. 验证 `flyway_schema_history` 不存在也不影响本地启动;
5. 生产迁移与本地脚本保持同一 DDL 语义,但不要求本地伪造 Flyway 历史。
---
## 10. 分阶段落地计划
阶段以依赖和退出条件驱动,不以“写完代码”作为完成标准。
### Phase 0:安全语义止血与契约冻结(1–2 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-01 统一词汇与 ADR | 固化 primary org、org manager、position、role、menu、subject、actor、event org 语义 | 代码、DB、API 文档不再混用 principal/primary |
| IA-02 AccessContext | 新增 resolver/filter,统一 effective tenant、user、surface、device、privacy、assignment、authzVersion tuple | Admin/Phone/Bigscreen/Watch 请求均可输出同一审计上下文;上下文中不存在可误用的扁平 dataScope |
| IA-03 DataScope fail-closed | `CUSTOM` 空配置返回空集;按 action 逐 assignment 编译 scope;实现 `AuthorizedSubjectScope`;先接 Health V2、Admin workbench、Mobile health、Bigscreen | 同租户跨组织/跨用户负向测试全部 403/404;SELF + 多组织角色组合结果符合 §5.3 |
| IA-04 组织角色接线 | 登录加载 `sys_user_role + sys_user_org_role` assignments;permission hint 可并集,资源访问必须由同一 assignment 同时授予 action 和 scope | 组织角色在绑定组织有效,在兄弟组织无效;不能跨 assignment 拼接 permission/scope |
| IA-05 菜单/权限收敛 | 路由装配应用 tenant override;增加 menu-permission 一致性校验;缓存版本化 | 隐藏/别名/排序生效,API 权限不依赖菜单 |
| IA-06 RLS 真实验证 | 修正关系表 tenant key/policy;落实事务 `SET LOCAL` 或 session set/reset/evict 纪律;用 `NOBYPASSRLS` 角色跑 CURRENT/TARGET/ALL 和连接复用测试 | 超级用户与受限角色业务结果一致;tenant A 的物理连接在异常/ALL 后不能污染 tenant B;越权查询被拒绝 |
| IA-07 Device Auth P0 | 以 `t_device_bind_approval` 为 provisioning 授权锚点,bootstrap possession challenge 后换取短期 token;签名含 method/path/body hash/timestamp/nonce | 审批不能替代设备身份证明;只持有 SN 无法上传、拉配置或收消息;bootstrap 和业务请求重放均被拒绝 |
| IA-08 负向契约矩阵 | tenant×org×user×surface×action 参数化测试 | CI 强制覆盖横向/纵向越权与伪造维度 |
| IA-09 授权版本服务 | 建立 platform/tenant/user version 事实表和唯一递增服务;同事务写 authorization outbox,多实例清缓存并对高风险收权撤销 Session/token | 任一实例修改授权后其他实例立即拒绝旧高风险凭据;普通缓存失效可由版本 + TTL + DB 回源证明 |
Phase 0 兼容策略:旧接口保留一个灰度周期,记录客户端传入维度与服务端解析维度的差异;先告警再拒绝,设备认证例外应通过明确的临时 allowlist,而不是永久信任 SN。
### Phase 1:关系与资源归属收敛(2–3 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-11 人员组织模型 | 拆分 `is_primary` 与 org manager;实现有效期、调动和状态端点;发布 assignment changed event | 调动单事务完成,权限/缓存/设备/告警影响可审计 |
| IA-12 岗位模型 | 岗位绑定组织和有效期;风险等级/权重进入统一策略服务 | 岗位变化不直接改变权限,健康规则能按事件时岗位取值 |
| IA-13 设备绑定事实源 | `t_device_user` 变为时间化 binding;`sys_user.device_sn/t_device_info.user_id` 降级为投影 | 同设备不能同时绑定两人,用户支持多设备,历史可追溯 |
| IA-14 资源信封 | 健康、告警、消息补齐 actor/subject/source/event-org 语义和字段 | 任一告警可追溯到原始事实和设备;消息可追溯到告警/发布人 |
| IA-15 查询索引 | 对候选索引做真实数据 EXPLAIN;只上线证明有效的组合/部分索引 | 核心组织/用户查询 P95 达标且写放大可接受 |
| IA-16 模型去重 | 选定每张核心表的 canonical Entity/Mapper,逐步删除重复模型和双列映射 | 同一物理列只由一个 canonical 属性映射 |
| IA-17 租户 Blueprint | 模板版本、差异预览、冲突处理、幂等同步和回滚 | 平台模板升级不覆盖租户自定义,新增权限可按 entitlement 灰度 |
### Phase 2:统一查询服务与可重建投影(2–4 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-21 SubjectScopeQueryService | 统一 current/event scope、org subtree、user set、keyset pagination | Controller 不再自行拼 customer/user/org 条件 |
| IA-22 领域查询 API | 收敛 Health/Device/Alert/Message 查询 DTO 和错误语义 | 四端同一资源得到一致权限与状态 |
| IA-23 组织/用户读模型 | 复用同一 outbox 事件;建设 health current、org daily、device current、alert workload、message inbox;Kafka 达到 shadow 晋级门禁后再承载跨进程 durable consumer | 每个投影有 source event id、幂等、rebuild、diff、lag、PG fallback;Spring/Kafka 不产生平行事实 |
| IA-24 Bigscreen 收敛 | 只改 realtime/snapshot/rebuildCache/examSummary/interventionSummary 等 query-first 端点,使 tenant/org 与 viewer scope 取交集;保留 `getAssignees/urgeExam/getAlertDetail` 的 `effectiveTenantSafe()` 参考实现;快照与 SSE cursor 对账 | 所有未收敛端点的 query tenantId 不能扩权,已收敛端点无重复改造,隐私模式不串缓存 |
| IA-25 可观测性 | 授权拒绝原因、scope 大小、查询延迟、缓存命中、投影 lag | 能区分认证失败、租户拒绝、数据范围拒绝和隐私裁剪 |
### Phase 3:多终端能力平台(2–3 个 Sprint)
| 工作包 | 实施内容 | 退出条件 |
|---|---|---|
| IA-31 Capability API | surface/tenant/role/device capability 合并,版本化缓存 | Admin/Phone/Bigscreen 可基于同一能力事实呈现 |
| IA-32 Viewer Principal | 大屏短期 token,固定 tenant/org/privacy/action | 无人值守大屏无普通管理员长期 token |
| IA-33 Device Principal | 手表/手环设备凭据轮换、撤销、离线队列和命令回执 | 设备遗失后可单独撤销,不影响用户账号 |
| IA-34 Edge Principal | 机器人/无人机/网关 capability、mission、command 权限 | 遥测与控制分权,所有命令可审计和幂等 |
| IA-35 第三方授权 | service account、API scope、Webhook 签名、字段最小化 | 外部系统不能复用平台管理员 token |
### Phase 4:隐私治理与规模化
- 用途/同意/字段级策略和紧急 break-glass;
- 小样本聚合保护、导出水印、敏感字段访问审计;
- 租户模板版本、差异预览、灰度同步和回滚;
- 策略模拟器:给定 actor/action/resource 输出允许/拒绝及每层证据;
- 大租户按时间分区、冷热分层、归档和投影容量治理。
---
## 11. 测试与验收矩阵
### 11.1 必测主体
- 平台超管 CURRENT/TARGET/ALL;
- 租户管理员;
- 部门负责人;
- 组织角色用户;
- SELF 普通用户;
- CUSTOM scope 用户;
- Bigscreen viewer;
- 已绑定/解绑/伪造/撤销设备;
- 内部 service 与第三方 service account。
### 11.2 必测资源
- 用户列表与详情;
- 菜单、permission、岗位和角色分配;
- 健康明细、时序、分析、组织聚合;
- 设备列表、绑定、心跳和同步状态;
- 告警列表、详情、确认、关闭、转派;
- 消息发布、收件箱、已读/确认、组织受众统计;
- Bigscreen snapshot/realtime/SSE/action。
### 11.3 四类断言
1. **正向**:合法主体在合法范围内可读写。
2. **横向越权**:同租户兄弟组织、其他用户、其他设备、其他收件人均拒绝。
3. **纵向越权**:普通用户不能分配高权限角色、切换租户、执行大屏/管理员动作。
4. **参数伪造**:body/query/header 的 customer/user/org/device 与服务端上下文冲突时拒绝并审计。
数据库测试必须同时运行:
- 超级用户,仅验证功能;
- `NOBYPASSRLS` 应用角色,验证真实 RLS;
- elevation/ALL 的受控事务角色,验证跨租户运维路径。
---
## 12. 性能与 SLO 建议
| 场景 | 目标 |
|---|---|
| 登录授权快照 | P95 < 200 ms,缓存命中 < 30 ms |
| Admin 组织树/用户筛选 | P95 < 300 ms |
| 用户健康时序 7 天 | P95 < 300 ms |
| 组织聚合 30 天 | 投影命中 P95 < 500 ms,PG fallback < 2 s |
| Bigscreen realtime | 新事件可见 < 5 s |
| Bigscreen snapshot | 缓存命中 < 200 ms,PG 重建 < 3 s |
| 告警工作队列 | P95 < 300 ms |
| Phone 收件箱 | P95 < 300 ms |
| 授权策略判定 | 缓存命中 P95 < 20 ms |
这些是首版容量目标,最终以生产数据量、租户规模和隐私策略评审后固化。
---
## 13. 实施顺序与依赖
```mermaid
flowchart LR
A[IA-01 语义 ADR] --> B[IA-02 AccessContext]
B --> C[IA-03 Scope fail-closed]
B --> D[IA-04 组织角色]
D --> E[IA-05 菜单/权限]
B --> V[IA-09 授权版本]
D --> V
E --> V
C --> F[IA-08 负向矩阵]
D --> F
E --> F
V --> F
B --> G[IA-07 Device Auth]
C --> H[IA-11 人员组织关系]
H --> I[IA-13 设备绑定]
H --> J[IA-14 资源信封]
I --> J
J --> K[IA-21 统一查询]
K --> L[IA-23 读模型]
L --> M[IA-31 多终端能力]
```
最小可交付切片不是“把所有表重构完”,而是:
```text
AccessContext
+ fail-closed DataScope
+ 组织角色接入登录
+ platform/tenant/user 授权版本与高风险收权
+ Health Workbench/Mobile/Bigscreen 三个高风险查询接线
+ 受限 RLS 与同租户越权负向测试
```
完成该切片后,后续设备绑定、资源信封、读模型和新终端才能在稳定授权边界上迭代。
---
## 14. 不在本轮做的事情
- 不在架构第一阶段引入独立 IAM 微服务;先在现有 Spring 模块内建立清晰接口与边界。
- 不把所有历史表一次性重命名为新字段;先统一逻辑语义和访问层,再渐进迁移物理模型。
- 不把 Redis 变成授权或健康事实源。
- 不让前端菜单替代后端权限。
- 不让岗位自动拥有管理员权限。
- 不为了支持未来设备而把 `user_id` 设为所有设备资源的必填字段。
- 不在本地创建或修复 `flyway_schema_history`。
---
## 15. 代码审计锚点
- 租户/用户上下文:`GlobalRequestInterceptor`、`UserContext`、`TenantContext`
- 登录角色与权限:`SysUserServiceImpl.saveUserToSession()`、`SysRoleMapper.xml`、`SysPermissionInterfaceImpl`
- 菜单路由:`AuthenticationFacadeImpl.queryUserRouteWithUserId()`、`SysMenuServiceImpl`
- 组织角色:`UserOrgRoleService`、`SysUserOrgRole`
- 数据范围:`DataScopeInterceptor`、`ScopeQueryFactory`
- 统一授权上下文:`AccessContext`、`GrantAssignment`、`AuthorizedSubjectScope`、`AccessContextHolder`、`RequestAccessContextResolver`
- Assignment 授权:`DefaultGrantAssignmentLoader`、`AssignmentResourceAuthorizer`、`PermissionSubjectScopeResolver`
- 管理边界:`AuthorizationChain`、`AdminBoundaryGuard`、`OrgScopeGuard`、`RoleDelegateGuard`
- 组织关系:`SysUserOrgServiceImpl`、`PrincipalBasedOrgManagerResolver`
- 健康工作台:`AdminHealthWorkbenchController`、`HealthWorkbenchServiceImpl`
- 多终端:`BigscreenController`、`MobileApiController`、`WatchApiController`
- 业务资源:`TUserHealthData`、`TDeviceInfo`、`TDeviceUser`、`TDeviceHeartbeat`、`TAlertInfo`、`TDeviceMessage`、`TDeviceMessageDetail`
- RLS:`R__rls_policies.sql`
---
## 16. 实施跟踪与版本记录
IA 编号是稳定的工作包标识,父跟踪项为 [GitHub Epic #383](https://github.com/lingjingwanxiang/ljwx-health/issues/383)。后续按 IA 工作包创建或复用 issue;issue 标题保留 IA 编号,退出条件原样进入 acceptance criteria,依赖关系按 §13 维护。创建前必须基于实时 issue/PR 状态做去重,不能仅凭文档预分配可能冲突的 issue number。
### 16.1 实施台账
| 工作包 | 状态 | 已落地 | 尚未完成 |
|---|---|---|---|
| IA-01 | IN_PROGRESS | v1.1 已固化 actor/subject、effective tenant、assignment、event org 等术语;公共代码引入同名强类型 | DB 字段、API 契约和旧 `principal/primary` 用法尚未全部迁移 |
| IA-02 | IN_PROGRESS | `AccessContext/GrantAssignment/AuthzVersion` 不可变契约;请求入口解析 HUMAN、platformAdmin、CURRENT/TARGET/ALL、server-bound surface、trace;ThreadLocal 前置清理和 completion 清理;授权资源首次访问时形成 request-local resolved snapshot | privacy policy、真实 authzVersion 尚未装入快照 |
| IA-03 | IN_PROGRESS | `CUSTOM`、空组织列表、缺失 SELF user 改为 fail closed;`AuthorizedSubjectScope` 按 permission 合并完整 assignments;Admin Health Workbench context/records/series/analysis/actions 已裁剪可见组织树、用户选项并在领域查询前校验 resource scope;Mobile `/mobile/health/data` 从 AccessContext 固定 operator/effective tenant,健康记录详情和告警详情/AI/处理按已落库 page/update 权限对 effective tenant 内当前 user 或 persisted assignee 做资源授权,仅无 user 时回退事件 org,角色名白名单已移除,`COMMON` 模板及租户克隆从 TENANT_ALL 更正为 SELF;Mobile 聚合状态、关键指标、告警计数/摘要和 AI 建议按 action permission 固定当前 tenant/user,AI 快照显式携带 tenant,缓存键纳入 scope/privacy/authz version,建议反馈新增独立 add permission 并写入服务端解析的 tenant/user,持久化失败不再伪报成功;Bigscreen realtime/snapshot/cache rebuild/exam/intervention/SSE 租户聚合与 Admin alert action/detail/assignees 从 permission-specific scope 固定 effective tenant,非 TENANT_ALL assignment 与 query tenant 扩大均返回 HTTP 403;SSE 复用已落库的 `bigscreen:realtime:read`,不再引用不存在的 subscribe permission;单组织 Viewer create 固定有效租户内组织,snapshot/realtime/SSE replay/live 强制 token org,健康、告警、设备、消息、体检、干预与空间聚合按 org 裁剪,事件缺少可信 org 时 fail closed,设备事件可从当前用户/设备关系解析可信 org;多组织 Viewer 暂时 fail closed;Viewer alert detail 在 Facade 前按持久化告警 tenant/org 校验 token org scope;Viewer create/bootstrap/revoke/revoke-tenant 管理操作按 `bigscreen:viewer:manage` 编译 tenant-wide assignment scope;Health V2 score trend/distribution/ranking 在进入 Facade 前按 endpoint permission 校验 user/org/tenant 聚合范围并覆盖 effective tenant;Health Profile V2 comprehensive/daily/behavior/risk 取消角色白名单,改由 `t:user:health:data:page` 的同 assignment subject scope 校验目标用户;Alert V2 list 固定服务端 tenant/user/org 查询维度,detail/handle/assign/status 在读写前将 endpoint permission 与持久化告警的当前 user/org 关系求交,批量更新先授权全部资源;Device Message 读取统一使用 `t:device:message:page` 编译服务端 tenant/user/org/resource scope;新增、修改、删除、分发、确认、状态、重试、清理与缓存操作复用已落库的 add/update/delete permission,写前完成资源和目标全量授权,opaque device/target 与租户级操作只允许 tenant-wide assignment,Service 写路径显式携带服务端租户 | 多组织 Viewer 的聚合/事件并集语义、其余 Health/Device 入口的组织/用户/资源范围及真正的组织级聚合仍待逐端接线 |
| IA-04 | IN_PROGRESS | `sys_user_role + sys_user_org_role` 分别装载为带 source/boundary/anchor 的 assignment;显式绕过 tenantLine 读取指定角色后严格校验关系租户、角色租户、组织锚点和 dataScope,仅兼容显式 `is_template=true` 的平台模板直绑;资源访问由同一 assignment 同时授予 action 与 scope;组织角色的 TENANT_ALL 仍受绑定组织子树约束;登录菜单已消费 resolved assignment role set | 其余高风险资源入口尚未切换;存量平台模板直绑应在 IA-05 迁移为租户角色副本 |
| IA-05 | IN_PROGRESS | Admin 路由装配改为读取 IA-04 resolved assignments,直属角色与组织角色的菜单/permission hint 均可投影;`sys_menu_tenant_override` 的隐藏、别名、排序进入租户路由,隐藏父菜单会隐藏整棵子树,别名清除 i18n key;应用覆盖前复制角色菜单对象,避免共享缓存跨租户污染 | menu-permission 全库一致性门禁、租户模板直绑迁移、navigation/authz version 缓存键与多实例失效尚未完成 |
| IA-07 | IN_PROGRESS | challenge 使用按设备 bootstrap proof 并单次消费;新增 15 分钟短 token、method/path/body hash/timestamp/nonce HMAC、Redis replay key、解绑撤销与 disabled/monitor/enforce 灰度模式;Redis 仅保存 token hash | 生产 KMS/Vault 按设备凭证、Phone/Watch 安全 provisioning、强制绑定签名及 monitor 到 enforce 的实证切换尚未完成 |
台账状态按退出条件判断,不能因类或接口已经创建就标记 DONE。2026-07-20 当前 changed slices 覆盖 AccessContext/请求解析、DataScope fail-closed、assignment loader/evaluator、Workbench/Mobile/Bigscreen/Health V2 资源 guard、assignment-aware tenant navigation 和短期设备认证;验证结果以 Epic #383 的进展记录为准。
### 16.2 版本记录
| 版本 | 日期 | 状态 | 变更 |
|---|---|---|---|
| v1.0 | 2026-07-10 | Proposed | 初版总体架构、事实核查和 IA-01~IA-35 落地计划 |
| v1.1 | 2026-07-10 | Accepted | 标注组织管理现状与 Bigscreen 已收敛端点;补齐 assignment 合并语义、RLS 连接池纪律、Outbox/Kafka 单事件源、授权版本属主、设备 provisioning 和实施跟踪规则 |
| v1.2 | 2026-07-10 | Accepted | 关联 Epic #383;记录 IA-04 assignment 装载、同 assignment 授权判定、permission-specific subject scope 与 Health Workbench 全入口接线 |
| v1.3 | 2026-07-19 | Accepted | 记录 IA-05 assignment-aware tenant navigation 与 IA-07 短期设备请求认证的阶段交付和剩余生产门禁 |
| v1.4 | 2026-07-19 | Accepted | 记录 IA-03 Mobile health subject scope 首段接线:服务端主体定界、兼容 hint 匹配与 HTTP 403 越权拒绝 |
| v1.5 | 2026-07-19 | Accepted | 记录 IA-03 Bigscreen 租户聚合首段接线:permission-specific tenant-wide scope、effective tenant 改写与 query tenant 扩大拒绝 |
| v1.6 | 2026-07-19 | Accepted | 记录 IA-03 Health V2 score 端点接线:permission-specific user/org/tenant 范围、effective tenant 改写与冲突维度拒绝 |
| v1.7 | 2026-07-20 | Accepted | 记录 IA-03 Health Profile V2 画像读接线:移除角色白名单,以现有健康数据读取 permission 编译 user/org/tenant subject scope,并统一 HTTP 403 越权语义 |
| v1.8 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen SSE 接线:复用 realtime read permission 编译 tenant-wide subject scope,以服务端 effective tenant 打开 replay/live channel,并拒绝跨租户 query hint |
| v1.9 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen Admin 告警入口接线:action、detail、assignees 在调用 Facade 前按各自 permission 要求 tenant-wide subject scope |
| v1.10 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen Viewer 告警详情资源校验:token org scope 与告警持久化 tenant/org 在 Facade 前求交,未知或越界资源统一拒绝 |
| v1.11 | 2026-07-20 | Accepted | 记录 IA-03 Bigscreen Viewer 管理面接线:create/bootstrap/revoke/revoke-tenant 按 `bigscreen:viewer:manage` 编译 tenant-wide assignment scope,非租户级授权在进入 session service 前拒绝 |
| v1.12 | 2026-07-20 | Accepted | 记录 IA-03 Alert V2 接线:list 固定服务端 tenant/user/org 维度,detail/handle/assign/status 在 Facade 或写服务前校验持久化告警与当前 user/org subject scope,批量更新先授权全部资源 |
| v1.13 | 2026-07-20 | Accepted | 记录 IA-03 Device Message 读取接线:page/export 固定服务端 tenant/user/org 维度,detail/summary 校验持久化消息当前 user/org 关系,opaque device/target 与租户聚合 fail closed 到 tenant-wide assignment |
| v1.14 | 2026-07-20 | Accepted | 记录 IA-03 Device Message 变更面接线:add/update/delete/distribution/status/cleanup/cache 复用持久化权限,资源与目标写前全量授权,opaque target 要求 tenant-wide scope,Service 写路径显式绑定服务端租户 |
| v1.15 | 2026-07-20 | Accepted | 记录 IA-03 Mobile 资源入口接线:健康记录详情及告警详情/AI/处理按持久化 page/update 权限求交 effective tenant 内当前 user 或 assignee,无 user 时回退事件 org;移除角色名白名单,并把普通员工 COMMON assignment 从 TENANT_ALL 更正为 SELF |
| v1.16 | 2026-07-20 | Accepted | 记录 IA-03 Mobile 聚合状态与反馈接线:健康/告警聚合及 AI 快照固定当前 tenant/user,缓存隔离 scope/privacy/authz version,反馈独立授权、租户持久化与失败透传 |
| v1.17 | 2026-07-20 | Accepted | 记录 IA-03 单组织 Bigscreen Viewer 聚合接线:token org 强制覆盖 snapshot/realtime/SSE,健康、告警、设备、消息、体检、干预与空间按组织裁剪,未知事件组织 fail closed,多组织 token 暂不放行 |
@@ -0,0 +1,113 @@
{
"artifact_id": "lingjingwanxiang__ljwx-health-08bcb565f851",
"documents": [
{
"approved_by": null,
"audience": [
"claude",
"codex"
],
"checksum": "544406cbb1fe005c727682f93a024de532e211a7f4865f0865f6bacb310ccb7c",
"content_path": "documents/ljwx-health-docs-02-architecture-bigscreen-viewer-token-md.md",
"doc_type": "design_spec",
"document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md",
"effective_from": null,
"excluded_from_retrieval": false,
"expires_at": null,
"op": "upsert",
"owner": "team-health",
"product_area": "ljwx-health",
"reason": null,
"review_status": "approved",
"source_id": "ljwx-health-architecture-ssot",
"source_path": "docs/02-architecture/bigscreen-viewer-token.md",
"source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/bigscreen-viewer-token.md",
"source_type": "repo_doc",
"tags": [
"ljwx-health",
"architecture",
"ssot"
],
"tier": "ssot",
"title": "Bigscreen Viewer Token 安全设计",
"version": "08bcb565f85173dc6193e38bc6a154070673f473",
"visibility": "internal"
},
{
"approved_by": null,
"audience": [
"claude",
"codex"
],
"checksum": "826cbf60fee98bb45c02d2bca21e855d23dbaaaece729099bc547871c41ead6e",
"content_path": "documents/ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md.md",
"doc_type": "design_spec",
"document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md",
"effective_from": null,
"excluded_from_retrieval": false,
"expires_at": null,
"op": "upsert",
"owner": "team-health",
"product_area": "ljwx-health",
"reason": null,
"review_status": "approved",
"source_id": "ljwx-health-architecture-ssot",
"source_path": "docs/02-architecture/business-data-chains/06-bigscreen-bff.md",
"source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/business-data-chains/06-bigscreen-bff.md",
"source_type": "repo_doc",
"tags": [
"ljwx-health",
"architecture",
"ssot"
],
"tier": "ssot",
"title": "LJWX Health 业务数据链 — 6. 大屏与运营 BFF 链路",
"version": "08bcb565f85173dc6193e38bc6a154070673f473",
"visibility": "internal"
},
{
"approved_by": null,
"audience": [
"claude",
"codex"
],
"checksum": "88ed27d713f4c4dda19b7d396c247077336c403e0faefbe10d1062e93ba7713f",
"content_path": "documents/ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md.md",
"doc_type": "design_spec",
"document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md",
"effective_from": null,
"excluded_from_retrieval": false,
"expires_at": null,
"op": "upsert",
"owner": "team-health",
"product_area": "ljwx-health",
"reason": null,
"review_status": "approved",
"source_id": "ljwx-health-architecture-ssot",
"source_path": "docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md",
"source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md",
"source_type": "repo_doc",
"tags": [
"ljwx-health",
"architecture",
"ssot"
],
"tier": "ssot",
"title": "多租户身份、权限、健康资源与多终端总体架构",
"version": "08bcb565f85173dc6193e38bc6a154070673f473",
"visibility": "internal"
}
],
"export_mode": "changed",
"generated_at": "2026-07-19T20:49:29.454158+00:00",
"schema_version": "0.1.0",
"source": {
"commit": "08bcb565f85173dc6193e38bc6a154070673f473",
"ref": "refs/heads/main",
"repo": "lingjingwanxiang/ljwx-health",
"run_attempt": 1,
"run_id": 29703215929,
"service": "ljwx-health",
"workflow": "knowledge-export"
}
}
@@ -0,0 +1,3 @@
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "544406cbb1fe005c727682f93a024de532e211a7f4865f0865f6bacb310ccb7c", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-bigscreen-viewer-token-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/bigscreen-viewer-token.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "Bigscreen Viewer Token 安全设计", "version": "08bcb565f85173dc6193e38bc6a154070673f473", "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "826cbf60fee98bb45c02d2bca21e855d23dbaaaece729099bc547871c41ead6e", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-business-data-chains-06-bigscreen-bff-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/business-data-chains/06-bigscreen-bff.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "LJWX Health 业务数据链 — 6. 大屏与运营 BFF 链路", "version": "08bcb565f85173dc6193e38bc6a154070673f473", "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "88ed27d713f4c4dda19b7d396c247077336c403e0faefbe10d1062e93ba7713f", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-tenant-identity-access-resource-terminal-architecture-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@08bcb565f85173dc6193e38bc6a154070673f473:docs/02-architecture/tenant-identity-access-resource-terminal-architecture.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "多租户身份、权限、健康资源与多终端总体架构", "version": "08bcb565f85173dc6193e38bc6a154070673f473", "visibility": "internal"}
@@ -0,0 +1,14 @@
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0000", "chunk_index": 0, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 25, "source_start_line": 1, "text_hash": "af0504017dc064e3c6819d662ae1d43f07fb0aebd786b175e997334202d48afa", "token_count": 293, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0001", "chunk_index": 1, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 46, "source_start_line": 27, "text_hash": "179944e913cbf6104130734d3ec684b17f6e75bd498bd75bc7191d16219f4f12", "token_count": 249, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0002", "chunk_index": 2, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 60, "source_start_line": 48, "text_hash": "a8ee20decd931927469661a56ec765c8e488e56d61fa82b07454b797f561f4b8", "token_count": 215, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0003", "chunk_index": 3, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 86, "source_start_line": 62, "text_hash": "08757587bba7d58825c9280a8c88c780b424ef525daf7d5a3425bdbf615fbac1", "token_count": 294, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0004", "chunk_index": 4, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 105, "source_start_line": 88, "text_hash": "5810e08f7b9f285fc338113cd0c690128537ee4a2bd0e1ee59ea31e1d7e5bf6e", "token_count": 266, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0005", "chunk_index": 5, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 122, "source_start_line": 107, "text_hash": "0d30c12edfa49de1d61ba2a1ff2682c9894479bb24054557e77bc3311dbc913d", "token_count": 188, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0006", "chunk_index": 6, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 138, "source_start_line": 124, "text_hash": "5e0a8d15d2bd84f4d05d9a4500a8fd9bbd9d702216db159b92e374c59c416a60", "token_count": 216, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md:chunk:0007", "chunk_index": 7, "customer_id": null, "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 149, "source_start_line": 140, "text_hash": "eab1a2c6025950636e7289da61f24c11e27d94732ac9fd1179125889483ce853", "token_count": 125, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md:chunk:0000", "chunk_index": 0, "customer_id": null, "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 24, "source_start_line": 1, "text_hash": "61083f06bb187f9101051ccae0e3216983e7a3299ed94c08f436d98ee011df07", "token_count": 261, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md:chunk:0001", "chunk_index": 1, "customer_id": null, "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 46, "source_start_line": 26, "text_hash": "48e60c3f91df005f91b7ed0a8bfde58253e554b10a670d9b6e975ec8b0e58cb4", "token_count": 295, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md:chunk:0002", "chunk_index": 2, "customer_id": null, "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 70, "source_start_line": 48, "text_hash": "1437f981ac7ffc558538b9b663de573f93c908334090d356a2143764e93c2ab2", "token_count": 220, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md:chunk:0003", "chunk_index": 3, "customer_id": null, "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 96, "source_start_line": 72, "text_hash": "fc4772b829655f5e972352d62656bfcf0e7e7f81b73c62f8e735b8a6d0ed4df6", "token_count": 279, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md:chunk:0004", "chunk_index": 4, "customer_id": null, "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 124, "source_start_line": 98, "text_hash": "fe9f816e0bb2eae42fd318f036c710547bb0b6c8b29f2695bdc0dfb97f229303", "token_count": 295, "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "chunk_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md:chunk:0005", "chunk_index": 5, "customer_id": null, "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "embedding_model": null, "heading_path": [], "published_targets": [], "source_end_line": 142, "source_start_line": 126, "text_hash": "515519bf9dc7126ce828edca381e78336b5aa4d10e8385170ce0531a489f557b", "token_count": 210, "visibility": "internal"}
@@ -0,0 +1,2 @@
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "44c9210b62aeeaf72cbd913880e0b08dfa6c2be8a3ba30ddbf18bbaf6c5e582d", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@0ea587470eb00796978bb16ed5598962e127ddf4:docs/02-architecture/full-collection-upload-rfc.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "RFC: Full Collection and Upload Pilot", "version": "0ea587470eb00796978bb16ed5598962e127ddf4", "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex", "product"], "checksum": "501ab3035474e2205fd8bd6d7c6826ccbe45364c1928be0302349c5d8bfb84c2", "customer_id": null, "doc_type": "operations_guide", "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@0ea587470eb00796978bb16ed5598962e127ddf4:docs/06-development/health-collection-policy-runbook.md", "source_type": "repo_doc", "tags": ["ljwx-health", "guides"], "tier": "reference", "title": "Health Collection Policy V2 Runbook", "version": "0ea587470eb00796978bb16ed5598962e127ddf4", "visibility": "internal"}
@@ -0,0 +1,149 @@
# RFC: Full Collection and Upload Pilot
- Status: Implemented behind a default-off pilot gate; production expansion blocked
- Parent: #359
- Baseline: `github/main`; merge and deployment evidence is tracked in #373, #374 and #376
- Pilot device: TD Tech Watch4, SDK V2.16
- Storage prerequisite: #348 and #375
- Operations: [collection policy runbook](../06-development/health-collection-policy-runbook.md)
## Decision
"Full collection" is not a product-wide switch. The platform may collect only a
metric that is supported by the device, explicitly consented by the user,
enabled by the tenant collection policy, and justified by an active processing
purpose. Presentation settings never grant collection permission.
Production defaults remain unchanged. A pilot can start only after every hard
gate in this RFC is green. Raw PPG, continuous ECG, audio and unrestricted
location are excluded from the initial pilot.
## Quantified Profiles
The following figures are planning envelopes, not measured battery claims.
Payload size includes JSON field names, identity, timestamps and batch framing;
transport compression is deliberately ignored so capacity is conservative.
| Profile | Record model | Records/device/day | Payload/device/day | Payload/device/30d |
|---|---:|---:|---:|---:|
| Current baseline | 9-metric snapshot, 480 B, 60 s | 1,440 | 0.69 MB | 20.7 MB |
| Pilot burst | 9-metric snapshot, 480 B, 5 s | 17,280 | 8.29 MB | 248.8 MB |
| Daily slow data | sleep/activity summary, 4 KiB | 1 | 0.004 MB | 0.12 MB |
| ECG continuous, excluded | 250 Hz, 2 B + 20% framing | 21.6 M samples | 51.8 MB | 1.55 GB |
For the approved 10-device, 14-day pilot:
| Profile | Ingress/day | 14-day ingress | 3.5x platform write envelope |
|---|---:|---:|---:|
| 60 s baseline | 6.9 MB | 96.6 MB | 338 MB |
| 5 s burst | 82.9 MB | 1.16 GB | 4.06 GB |
The 3.5x envelope covers PostgreSQL wide/raw facts, Kafka replication and
indexes; Redis caches are measured separately. Extrapolation to 10,000 devices
at 5 s is 82.9 GB/day ingress and roughly 290 GB/day platform writes, therefore
5 s cannot become a fleet default without #348/#375 capacity evidence.
## Metric Policy
| Metric | Capability | Default collection | Pilot maximum | Processing purpose | Retention class |
|---|---|---:|---:|---|---|
| Heart rate | Watch4 supported | 60 s | 5 s burst | alert, score | hot/warm |
| Blood oxygen | Watch4 supported | 5 min | 60 s | alert, score | hot/warm |
| Temperature | device-dependent | 5 min | 60 s | alert | hot/warm |
| Blood pressure | SDK estimate | 10 min | 5 min | trend only unless tier allows | hot/warm |
| Stress | Watch4 supported | 5 min | 60 s | profile | hot/warm |
| Steps/calorie/distance | cumulative | 5 min | 60 s | profile | daily after 7 d |
| Sleep stages | daily summary | daily | daily | profile | warm |
| Location | separate consent | off | 60 s while duty active | geofence only | 7 d hot |
| Raw PPG/ECG/audio | unavailable or sensitive | prohibited | prohibited | none | none |
## Compliance Matrix
| Data class | Consent | Tenant control | Export/delete | Default |
|---|---|---|---|---|
| Vital signs | explicit health-data consent | metric allowlist | user export; tenant deletion workflow | minimum frequency |
| Sleep/activity | explicit health-data consent | metric allowlist | user export; tenant deletion workflow | summaries only |
| Precise location | separate purpose-bound consent | duty/geofence policy | 7-day deletion target | off |
| Medical-tier ECG/BP | device certification plus explicit consent | restricted role and audit | immutable export log; deletion policy review | off |
| Research/raw signals | study-specific consent | isolated research tenant | study protocol | prohibited |
Consent revocation stops future collection and upload immediately. It does not
silently delete legal-retention data; deletion uses the audited tenant workflow.
No policy may infer consent from device capability or UI visibility.
## Protocol Evolution
Client hello:
```json
{
"protocolVersion": 2,
"firmwareVersion": "vendor-version",
"deviceModel": "TD-WATCH4",
"capabilityVersion": "tdtech-v2.16",
"metrics": {"heart_rate": {"minIntervalSeconds": 5}}
}
```
Server policy response includes `policyVersion`, per-metric interval and network
conditions, `maxBatchRecords`, `maxOfflineBytes`, `retryAfterSeconds` and an
expiry. Upload batches carry `batch_id`; records carry `record_id/client_seq`.
The existing per-record ACK remains authoritative. HTTP 429 and server throttle
must reduce sampling/upload pressure with jittered backoff.
Protocol v1 clients continue to use `is_enabled/frequency_interval`. Unknown
capability versions receive the current 60 s baseline and cannot receive burst
policies. Offline storage is capped at 25 MB or 24 hours, whichever comes first;
oldest non-critical samples are compacted to summaries before deletion.
## Configuration Migration
| Legacy field | collection policy | upload policy | processing policy | presentation policy |
|---|---|---|---|---|
| `is_enabled=false` | disabled | disabled | disabled | hidden |
| `is_enabled=true` | consent/capability gated | enabled | enabled | visible |
| `frequency_interval` | sampling interval | flush upper bound | no effect | no effect |
Migration uses one release of dual read with the legacy fields authoritative.
The new policy becomes authoritative only after tenant-level comparison is clean.
Rollback switches reads back to legacy fields; it never rewrites device firmware.
## Capability Ownership
| Component | Responsibility | Tracking |
|---|---|---|
| `ljwx-phone` / device SDK | capability hello, offline queue, throttle | #373 |
| boot ingress | policy negotiation, consent gate, idempotent batch | #374 |
| PostgreSQL/Kafka/archive | capacity and restore gate | #375 |
| score/alert/profile | processing allowlist | #376 |
| admin UI | presentation policy and consent visibility | #376 |
| pilot/testops | experiment and rollback evidence | #377 |
## Pilot and Stop Conditions
- One tenant, TD Tech Watch4, 10 devices, 14 days.
- Start at 60 s for 48 hours; allow 5 s heart-rate burst for at most 30 minutes
per device per day after the baseline is stable.
- Success: loss <= 0.5%, upload-to-cache p95 <= 5 s, no database lock timeout,
Kafka lag <= 60 s, archive verification 100%, and added battery drain <= 8
percentage points per 24 hours versus the same-device baseline.
- Stop immediately on consent/tenant leakage, unverified archive deletion,
crash-loop, > 2% loss, > 15 minutes of p95 > 10 s, > 15 minutes of Kafka lag
> 5 minutes, or > 12 percentage points added battery drain.
- Rollback sets the tenant pilot flag off, expires the v2 policy, returns clients
to the 60 s legacy mapping and keeps already accepted records readable.
## Review Record
Approval is external evidence and must not be inferred from this commit.
| Reviewer | Required evidence | Status |
|---|---|---|
| Product | purpose and user experience | pending |
| Algorithm | metric usefulness and precision tier | pending |
| Firmware/mobile | capability and battery measurements | pending |
| Backend/data | load and reconciliation report | pending |
| Operations | dashboards, alerts and rollback drill | pending |
| Compliance | consent, minimization, export/delete | pending |
No reviewer row may be marked approved without a linked artifact in #377.
@@ -0,0 +1,142 @@
# Health Collection Policy V2 Runbook
This runbook covers the default-off implementation for #373, #374 and #376.
The product decision and pilot gates remain in
[the full collection RFC](../02-architecture/full-collection-upload-rfc.md).
## State boundaries
Always name the baseline when reporting status:
- `github/main`: code and reusable local SQL committed to the application repo.
- Docker development DB: `ljwx-local-postgres17/ljwx_health`.
- Docker system-test DB: `ljwx-local-postgres17/ljwx_health_test`.
- k3s: production-like runtime managed by `ljwx-deploy` and Argo CD.
Table creation is not pilot activation. The compatibility seed creates an
`ACTIVE` shadow policy with `pilot_enabled=false`; old clients and current
production behavior remain on the legacy contract. Negotiation reports
`mode=SHADOW` and `enforced=false` and returns legacy metric semantics until the
pilot gate is on. An exact tenant/model pilot
must not be enabled until #377 contains the required approval and stop-condition
evidence.
## Runtime contract
| Dimension | Enforced by | Effect when disabled |
|---|---|---|
| collection | negotiated phone/device policy | client does not sample the metric |
| upload | boot ingress before `DataUpload` | request fails closed with 403; no fact/raw write |
| processing | persisted-event/outbox fan-out | stored fact remains, but score/alert/profile copies have the metric removed |
| presentation | unified health query page paths | stored and analysis facts remain, but user-facing copies have the metric removed |
The server resolves a model-specific policy from `t_device_info.model` for the
bound tenant and device. A client-provided model is diagnostic only and cannot
select a model-specific policy. If no registered model is available, only the
tenant wildcard policy can match.
Protocol V2 requires `capabilityVersion`, supported metrics, `policyVersion`,
`batch_id`, and stable record identity. Protocol V1 or an upload without a
policy version stays in the compatibility window. A stale V2 policy returns
409; an unauthorized metric returns 403; batch or request throttling returns
429 with `Retry-After`.
## Local application
Local development uses direct SQL and never Flyway:
```bash
rtk env PG_APPLY_MODE=docker PG_DATABASE=ljwx_health \
ljwx-boot/scripts/apply-local-sql.sh \
ljwx-boot/scripts/sql/V_local__health_collection_policy_v2.sql
```
The script is idempotent. Applying it again must not create duplicate policy
versions or metric rows and must not turn a rolled-back policy back on.
Check the default-off state in the Docker development DB:
```bash
rtk docker exec ljwx-local-postgres17 psql -U ljwx -d ljwx_health -c \
"SELECT customer_id, device_model, policy_version, status, pilot_enabled FROM t_health_policy_version ORDER BY customer_id, device_model;"
```
Use `ljwx-boot/scripts/ensure-local-test-db.sh` before destructive system-test
work so the separate `ljwx_health_test` schema remains the test baseline.
## Activation gates
An exact tenant/model policy can be activated only when all of the following are
linked from #377:
1. product, algorithm, firmware/mobile, backend/data, operations and compliance approvals;
2. registered device model and capability version match the approved pilot;
3. #348 and #375 capacity, archive and restore gates are green;
4. dashboards and stop alerts cover loss, p95 latency, database lock timeout,
Kafka lag, archive verification and battery drain;
5. the rollback owner has completed a drill.
Production schema changes must be delivered by a reviewed `ljwx-deploy` GitOps
resource. Do not run the local SQL helper manually against k3s. Activation must
be a separate, auditable change from schema installation and must leave the
wildcard compatibility policy default-off.
## Observability
Phone diagnostics expose and log:
- queued records and bytes;
- oldest record age;
- capacity/expiry drops;
- permanently rejected records.
HTTP 429 writes a queue-wide blocked-until timestamp, so a repeated flush cannot
send another record during `Retry-After`. Retries use exponential backoff and
jitter. Accepted and permanently rejected record IDs are returned to the caller
for per-record outcome handling.
Ingress returns stable error codes: `POLICY_VERSION_MISMATCH`,
`METRIC_UPLOAD_NOT_AUTHORIZED`, `BATCH_LIMIT_EXCEEDED`, and `UPLOAD_THROTTLED`.
The current request window is maintained per boot replica. A multi-replica
rollout must either account for that aggregate limit at the ingress layer or
replace it with a shared limiter before fleet-wide activation.
## Rollback
For the Docker development DB, retire all active V2 policies while preserving
tables, audit history and accepted facts:
```bash
rtk env PG_APPLY_MODE=docker PG_DATABASE=ljwx_health \
ljwx-boot/scripts/apply-local-sql.sh \
ljwx-boot/scripts/sql/V_local__health_collection_policy_v2_rollback.sql
```
The rollback sets `status=ROLLED_BACK` and `pilot_enabled=false`, then records a
`ROLLBACK` audit event. Clients fall back to the legacy policy on the next
negotiation; no firmware downgrade or historical-data deletion is required.
Production rollback is the corresponding reviewed GitOps job/change. After a
rollback, verify that no V2 policy is active, the negotiation response names
`legacy-v1`, 429/403 rates return to baseline, and already accepted records are
still queryable.
## Verification matrix
| Contract | Evidence |
|---|---|
| new/old client compatibility | `HealthDataConfigServiceImplTest`, `HealthMetricPolicyServiceTest` |
| tenant/model authority and fail-closed upload | `HealthDataStreamServiceImplTest`, `HealthMetricPolicyServiceTest` |
| processing and presentation independence | `HealthDataShardProcessorTest`, `UnifiedHealthDataPresentationPolicyTest` |
| offline, duplicate, capacity and Retry-After behavior | `health_upload_queue_test.dart` |
| local schema, RLS and rollback | direct SQL against `ljwx_health_test` plus schema-parity report |
Run the impacted selector after the branch has a commit so its
`github/main..HEAD` diff includes all changed files:
```bash
rtk env LJWX_IMPACTED_BASE_REF=github/main \
LJWX_IMPACTED_HEAD_REF=HEAD \
LJWX_IMPACTED_CODEGRAPH_MODE=auto \
scripts/ljwx-test impacted
```
@@ -0,0 +1,81 @@
{
"artifact_id": "lingjingwanxiang__ljwx-health-0ea587470eb0",
"documents": [
{
"approved_by": null,
"audience": [
"claude",
"codex"
],
"checksum": "44c9210b62aeeaf72cbd913880e0b08dfa6c2be8a3ba30ddbf18bbaf6c5e582d",
"content_path": "documents/ljwx-health-docs-02-architecture-full-collection-upload-rfc-md.md",
"doc_type": "design_spec",
"document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md",
"effective_from": null,
"excluded_from_retrieval": false,
"expires_at": null,
"op": "upsert",
"owner": "team-health",
"product_area": "ljwx-health",
"reason": null,
"review_status": "approved",
"source_id": "ljwx-health-architecture-ssot",
"source_path": "docs/02-architecture/full-collection-upload-rfc.md",
"source_ref": "lingjingwanxiang/ljwx-health@0ea587470eb00796978bb16ed5598962e127ddf4:docs/02-architecture/full-collection-upload-rfc.md",
"source_type": "repo_doc",
"tags": [
"ljwx-health",
"architecture",
"ssot"
],
"tier": "ssot",
"title": "RFC: Full Collection and Upload Pilot",
"version": "0ea587470eb00796978bb16ed5598962e127ddf4",
"visibility": "internal"
},
{
"approved_by": null,
"audience": [
"claude",
"codex",
"product"
],
"checksum": "501ab3035474e2205fd8bd6d7c6826ccbe45364c1928be0302349c5d8bfb84c2",
"content_path": "documents/ljwx-health-docs-06-development-health-collection-policy-runbook-md.md",
"doc_type": "operations_guide",
"document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md",
"effective_from": null,
"excluded_from_retrieval": false,
"expires_at": null,
"op": "upsert",
"owner": "team-health",
"product_area": "ljwx-health",
"reason": null,
"review_status": "approved",
"source_id": "ljwx-health-guides",
"source_path": "docs/06-development/health-collection-policy-runbook.md",
"source_ref": "lingjingwanxiang/ljwx-health@0ea587470eb00796978bb16ed5598962e127ddf4:docs/06-development/health-collection-policy-runbook.md",
"source_type": "repo_doc",
"tags": [
"ljwx-health",
"guides"
],
"tier": "reference",
"title": "Health Collection Policy V2 Runbook",
"version": "0ea587470eb00796978bb16ed5598962e127ddf4",
"visibility": "internal"
}
],
"export_mode": "changed",
"generated_at": "2026-07-19T09:37:41.342273+00:00",
"schema_version": "0.1.0",
"source": {
"commit": "0ea587470eb00796978bb16ed5598962e127ddf4",
"ref": "refs/heads/main",
"repo": "lingjingwanxiang/ljwx-health",
"run_attempt": 1,
"run_id": 29681896276,
"service": "ljwx-health",
"workflow": "knowledge-export"
}
}
@@ -0,0 +1,2 @@
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex"], "checksum": "44c9210b62aeeaf72cbd913880e0b08dfa6c2be8a3ba30ddbf18bbaf6c5e582d", "customer_id": null, "doc_type": "design_spec", "document_id": "ljwx-health-docs-02-architecture-full-collection-upload-rfc-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@0ea587470eb00796978bb16ed5598962e127ddf4:docs/02-architecture/full-collection-upload-rfc.md", "source_type": "repo_doc", "tags": ["ljwx-health", "architecture", "ssot"], "tier": "ssot", "title": "RFC: Full Collection and Upload Pilot", "version": "0ea587470eb00796978bb16ed5598962e127ddf4", "visibility": "internal"}
{"acl_expression": "visibility == \"internal\"", "approved_by": null, "audience": ["claude", "codex", "product"], "checksum": "501ab3035474e2205fd8bd6d7c6826ccbe45364c1928be0302349c5d8bfb84c2", "customer_id": null, "doc_type": "operations_guide", "document_id": "ljwx-health-docs-06-development-health-collection-policy-runbook-md", "effective_from": null, "excluded_from_retrieval": false, "expires_at": null, "owner": "team-health", "product_area": "ljwx-health", "review_status": "approved", "source_ref": "lingjingwanxiang/ljwx-health@0ea587470eb00796978bb16ed5598962e127ddf4:docs/06-development/health-collection-policy-runbook.md", "source_type": "repo_doc", "tags": ["ljwx-health", "guides"], "tier": "reference", "title": "Health Collection Policy V2 Runbook", "version": "0ea587470eb00796978bb16ed5598962e127ddf4", "visibility": "internal"}

Some files were not shown because too many files have changed in this diff Show More