Compare commits
323
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae6eb6416 | ||
|
|
6833e1253a | ||
|
|
94806b6876 | ||
|
|
96db8706b5 | ||
|
|
188cd35e41 | ||
|
|
2b2cd69428 | ||
|
|
6cee4f8d41 | ||
|
|
08936fd1d7 | ||
|
|
1c86d14a90 | ||
|
|
0f7f812907 | ||
|
|
cad2b157a8 | ||
|
|
989ef61e8e | ||
|
|
7345fa7342 | ||
|
|
45256de506 | ||
|
|
9f4c1fda01 | ||
|
|
8a7e6469d0 | ||
|
|
6b220a414e | ||
|
|
16d1c987cd | ||
|
|
5772d2f3d0 | ||
|
|
067a6baf56 | ||
|
|
bedb1058f9 | ||
|
|
068ef334f5 | ||
|
|
08ed4e22c6 | ||
|
|
75f392df7a | ||
|
|
75c0c4bad3 | ||
|
|
b236ed97ce | ||
|
|
7de60242c5 | ||
|
|
8dd9827602 | ||
|
|
fc9dbaa9d1 | ||
|
|
f35f8a4c00 | ||
|
|
d02154f0d0 | ||
|
|
65fac99336 | ||
|
|
9d331f95c2 | ||
|
|
b0a034b238 | ||
|
|
1aa41d4c34 | ||
|
|
3ad88f6093 | ||
|
|
7f8f3bc42a | ||
|
|
c8b3d519ed | ||
|
|
0a1e82057c | ||
|
|
4f9185ff41 | ||
|
|
1a4ef9a2a0 | ||
|
|
3fbe0b1e5f | ||
|
|
7c9d0f2b84 | ||
|
|
e689ca78b0 | ||
|
|
fb2cd322ad | ||
|
|
ba889e2764 | ||
|
|
4ac6141c62 | ||
|
|
ed803105c0 | ||
|
|
04f4aca415 | ||
|
|
2207d9bb5e | ||
|
|
dd8dfdba8b | ||
|
|
a8b0161956 | ||
|
|
6dd3ac5f7e | ||
|
|
d844c543b8 | ||
|
|
d5dcfa58f9 | ||
|
|
9b19aeb43e | ||
|
|
8242c81420 | ||
|
|
b3ed59443a | ||
|
|
ad7e30d683 | ||
|
|
d82891d8aa | ||
|
|
d2e0f358b6 | ||
|
|
08496d14fb | ||
|
|
02f1dea1a7 | ||
|
|
d8486de967 | ||
|
|
cecd98e8c2 | ||
|
|
9e94d56db6 | ||
|
|
9e259fa1de | ||
|
|
447f224937 | ||
|
|
f04706c825 | ||
|
|
87d41b083e | ||
|
|
f318682ca2 | ||
|
|
1834f3805b | ||
|
|
64fd330886 | ||
|
|
77793e4dac | ||
|
|
d677a9ec66 | ||
|
|
044f33475e | ||
|
|
490845aa76 | ||
|
|
79556e8bba | ||
|
|
6e65776e26 | ||
|
|
512ada635a | ||
|
|
5f2ab3b635 | ||
|
|
6a715dd846 | ||
|
|
9932d8349a | ||
|
|
8e46e14168 | ||
|
|
abe6e54148 | ||
|
|
547713ff17 | ||
|
|
803e2a4e28 | ||
|
|
862f865e41 | ||
|
|
06327d805a | ||
|
|
c889e4ea50 | ||
|
|
21382dae97 | ||
|
|
920ee609ac | ||
|
|
0fc8ca4838 | ||
|
|
0b9753bad2 | ||
|
|
b0adb9d144 | ||
|
|
eba40a0b32 | ||
|
|
0efba2432d | ||
|
|
f3a7487eb0 | ||
|
|
764999867f | ||
|
|
35ca653b65 | ||
|
|
fcdeb19fab | ||
|
|
37f4b9592c | ||
|
|
879aafe92c | ||
|
|
036d1740ba | ||
|
|
faeff4066e | ||
|
|
7723996bec | ||
|
|
0b2afcfc5d | ||
|
|
549e2da497 | ||
|
|
fa45ca4fd5 | ||
|
|
32aa46c7dc | ||
|
|
9230fe7078 | ||
|
|
5a8da59f6c | ||
|
|
ec53059aa5 | ||
|
|
3c9c6298ef | ||
|
|
3634f2a631 | ||
|
|
1e5164b2b8 | ||
|
|
35f37c1889 | ||
|
|
c7dc7bb9e7 | ||
|
|
e606a39b05 | ||
|
|
b35f862a5d | ||
|
|
89e69a2faf | ||
|
|
cc80ccb6b9 | ||
|
|
abb2148262 | ||
|
|
6984e6fff1 | ||
|
|
05880ca9d6 | ||
|
|
37e8af7a1a | ||
|
|
0ff1a7b4f9 | ||
|
|
1016d04dd9 | ||
|
|
f253a06500 | ||
|
|
f7f17b9cd9 | ||
|
|
7c76a83b38 | ||
|
|
f4a501a5e1 | ||
|
|
da0aefe389 | ||
|
|
1c7f9276d0 | ||
|
|
378e359a39 | ||
|
|
259535340e | ||
|
|
20eb3a913c | ||
|
|
7aea121e51 | ||
|
|
241ece5b8d | ||
|
|
9ccaab082e | ||
|
|
cfcbf9da77 | ||
|
|
d08f1ac62f | ||
|
|
e153ebbee0 | ||
|
|
eb786d85d9 | ||
|
|
a1a8ccc81e | ||
|
|
d291674d4b | ||
|
|
8ede28e8b3 | ||
|
|
d935654dda | ||
|
|
3a59f67718 | ||
|
|
028f854d48 | ||
|
|
dadf08a252 | ||
|
|
6dd2957251 | ||
|
|
3eeff2b713 | ||
|
|
b3e1dd8874 | ||
|
|
4ea1f5a948 | ||
|
|
0e5a4de709 | ||
|
|
c7926295e9 | ||
|
|
e04b3ab6b1 | ||
|
|
0e3cf92b03 | ||
|
|
6cda8bb429 | ||
|
|
07966b7f09 | ||
|
|
fd2193d593 | ||
|
|
60f89856e9 | ||
|
|
3257548c70 | ||
|
|
2781ba1cb5 | ||
|
|
41a53da3e7 | ||
|
|
c8d3aee483 | ||
|
|
7e51e69e5a | ||
|
|
efa7311dcc | ||
|
|
7a1b4006d0 | ||
|
|
578c46db63 | ||
|
|
79fb8e0e28 | ||
|
|
971d251559 | ||
|
|
effa3ce27e | ||
|
|
15d3cdeac9 | ||
|
|
106416f499 | ||
|
|
892ad95068 | ||
|
|
b1d13bc483 | ||
|
|
bd135715be | ||
|
|
e9dac31682 | ||
|
|
71a3906451 | ||
|
|
bcc29f320d | ||
|
|
96d1cf5922 | ||
|
|
e22f0b91ef | ||
|
|
77423cd0d2 | ||
|
|
98fe72c6a6 | ||
|
|
14fb6875a0 | ||
|
|
b14b349305 | ||
|
|
58a314591b | ||
|
|
bbc5c1caf5 | ||
|
|
7be4709589 | ||
|
|
314945fe9c | ||
|
|
20f8248f56 | ||
|
|
f6fbdb7cce | ||
|
|
2dba97bae0 | ||
|
|
ae313c0081 | ||
|
|
abb95b301e | ||
|
|
f890756a4d | ||
|
|
f69a5809b6 | ||
|
|
4e1280021f | ||
|
|
2b33eddb54 | ||
|
|
313d19e5f8 | ||
|
|
abce4ffd0d | ||
|
|
f71a157b17 | ||
|
|
0e0ec87ac9 | ||
|
|
a296a734a1 | ||
|
|
3e44077278 | ||
|
|
288b05df27 | ||
|
|
d969b9553a | ||
|
|
6f8d51c4cc | ||
|
|
3cd11977cc | ||
|
|
f4b43e13e7 | ||
|
|
ff1f5abc06 | ||
|
|
90b7d89521 | ||
|
|
78593a0d0f | ||
|
|
47db834995 | ||
|
|
358d1ed14b | ||
|
|
0e74cc7f5e | ||
|
|
d19d4ec83a | ||
|
|
c2990b1cae | ||
|
|
6a46f19367 | ||
|
|
6c15620ca8 | ||
|
|
83501132f8 | ||
|
|
e30e3a13e7 | ||
|
|
bfb12ac4b4 | ||
|
|
f68e754ce5 | ||
|
|
9b35c5348a | ||
|
|
7a407b8d0d | ||
|
|
2f4fee1f3e | ||
|
|
12470dc7c5 | ||
|
|
40ade10f6f | ||
|
|
a30393a3bd | ||
|
|
ae88332b8d | ||
|
|
cb1017c3fc | ||
|
|
eae432f71f | ||
|
|
8a750cb4e7 | ||
|
|
7593af02f9 | ||
|
|
c34655931f | ||
|
|
8d8c732a13 | ||
|
|
e4936e777a | ||
|
|
256bf61b2b | ||
|
|
8acc29b489 | ||
|
|
7d414e78a1 | ||
|
|
4cd8109816 | ||
|
|
048efeece8 | ||
|
|
056d9b4431 | ||
|
|
be37ac7452 | ||
|
|
a70913b502 | ||
|
|
314fc0d8df | ||
|
|
4f68d5e9eb | ||
|
|
399575ad79 | ||
|
|
96a117bb8d | ||
|
|
f6e33dba07 | ||
|
|
0880a6512e | ||
|
|
5634fa9ed2 | ||
|
|
35c0880463 | ||
|
|
07bd50b8dc | ||
|
|
2dbbd32b6f | ||
|
|
b0c3f625f0 | ||
|
|
b641a766cf | ||
|
|
5d057c9a81 | ||
|
|
2736b8b610 | ||
|
|
01429bfe7c | ||
|
|
b3d05f697d | ||
|
|
eb69037821 | ||
|
|
41f4879f1e | ||
|
|
1f047e7969 | ||
|
|
bd7751def1 | ||
|
|
29126e1528 | ||
|
|
ff94196547 | ||
|
|
16f3da789a | ||
|
|
0d5d595801 | ||
|
|
8e9666b1f5 | ||
|
|
f826671af2 | ||
|
|
c986f3690d | ||
|
|
87f42bb267 | ||
|
|
d06f1a8660 | ||
|
|
bd3488243a | ||
|
|
6801d61662 | ||
|
|
1873be591d | ||
|
|
a4f2fcc330 | ||
|
|
86fdcda98b | ||
|
|
e69c2bedbf | ||
|
|
4bf87e5ba3 | ||
|
|
29bed96c93 | ||
|
|
3370f632d5 | ||
|
|
44667c9ac9 | ||
|
|
43fe777b11 | ||
|
|
cba46e8bfe | ||
|
|
f0a24211c8 | ||
|
|
b232dc926d | ||
|
|
0845eadcca | ||
|
|
d0a2700cd1 | ||
|
|
4d0ce22a45 | ||
|
|
a6533accc0 | ||
|
|
06b81dca4e | ||
|
|
a35165bc34 | ||
|
|
5d070854c8 | ||
|
|
cb4acc8686 | ||
|
|
1baaa020d2 | ||
|
|
301c131ff1 | ||
|
|
e9e6c93249 | ||
|
|
5b845d9c16 | ||
|
|
4595681367 | ||
|
|
a7113a98a4 | ||
|
|
251d1d2184 | ||
|
|
20e205f7d6 | ||
|
|
1485bc8c92 | ||
|
|
c75bdbc87c | ||
|
|
ff1c4d7cb2 | ||
|
|
f59174c38c | ||
|
|
2fb2998a73 | ||
|
|
93ffb4da90 | ||
|
|
a66007da4e | ||
|
|
0d18d908d2 | ||
|
|
c43fabbcf5 | ||
|
|
05e1c4bd6b | ||
|
|
071e23d92e | ||
|
|
49fb9f8ce0 | ||
|
|
3054dc6b4b | ||
|
|
8462b9e593 | ||
|
|
c74ed25a4f | ||
|
|
e0448b0683 |
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+286
@@ -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 }));
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
+37
@@ -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"}
|
||||
+1
@@ -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"}
|
||||
+937
@@ -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,批量更新先授权全部资源 |
|
||||
+49
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
@@ -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"}
|
||||
+47
@@ -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"}
|
||||
+3
@@ -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"}
|
||||
+89
@@ -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。
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# LJWX Health 业务数据链 — 6. 大屏与运营 BFF 链路
|
||||
|
||||
> business-data-chains 分册。章节编号沿用总目录(§ 前缀与原单文件一致),总览、当前实现摘要与跨链关系见 [README.md](README.md)。
|
||||
|
||||
**版本**: v1.9 · **最后更新**: 2026-07-19(冻结 V6 产品口径与 viewer token 安全设计)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> 可维护源与 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 系列。
|
||||
+942
@@ -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 暂不放行 |
|
||||
+113
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
@@ -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"}
|
||||
+14
@@ -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"}
|
||||
+2
@@ -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"}
|
||||
+149
@@ -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.
|
||||
+142
@@ -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
|
||||
```
|
||||
+81
@@ -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"
|
||||
}
|
||||
}
|
||||
+2
@@ -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
Reference in New Issue
Block a user