feat(delivery): add compose and k3s artifact deployment flow
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
DB_NAME=ljwx_platform
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
|
||||
BACKEND_PORT=18080
|
||||
ADMIN_PORT=18081
|
||||
SCREEN_PORT=18082
|
||||
|
||||
SPRING_PROFILES_ACTIVE=dev
|
||||
ALLOW_WEAK_PASSWORD=0
|
||||
|
||||
# Frontend build-time variables
|
||||
ADMIN_API_BASE=/api
|
||||
SCREEN_API_BASE=/api
|
||||
ADMIN_APP_TITLE="LJWX Platform Admin"
|
||||
SCREEN_APP_TITLE="LJWX Platform Screen"
|
||||
@@ -0,0 +1,20 @@
|
||||
DB_NAME=ljwx_platform
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
|
||||
POSTGRES_IMAGE=postgres:16.12-alpine
|
||||
REDIS_IMAGE=redis:7.2.5-alpine
|
||||
|
||||
# Use immutable references whenever possible.
|
||||
# Example tag style: harbor.omniverseai.net/app/ljwx-platform:sha-b7766e9
|
||||
# Example digest style: harbor.omniverseai.net/app/ljwx-platform@sha256:abc...
|
||||
BACKEND_IMAGE=harbor.omniverseai.net/app/ljwx-platform:sha-CHANGE_ME
|
||||
ADMIN_IMAGE=harbor.omniverseai.net/app/ljwx-platform-admin-ui:sha-CHANGE_ME
|
||||
SCREEN_IMAGE=harbor.omniverseai.net/app/ljwx-platform-screen:sha-CHANGE_ME
|
||||
|
||||
BACKEND_PORT=18080
|
||||
ADMIN_PORT=18081
|
||||
SCREEN_PORT=18082
|
||||
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
ALLOW_WEAK_PASSWORD=0
|
||||
@@ -0,0 +1,30 @@
|
||||
NAMESPACE=ljwx-platform
|
||||
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
DB_HOST=postgres-lb.infra.svc.cluster.local
|
||||
DB_PORT=5432
|
||||
DB_NAME=ljwx_platform
|
||||
REDIS_HOST=redis-lb.infra.svc.cluster.local
|
||||
REDIS_PORT=6379
|
||||
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
REDIS_PASSWORD=CHANGE_ME_IF_REQUIRED
|
||||
JWT_SECRET=CHANGE_ME_TO_LONG_RANDOM_SECRET
|
||||
|
||||
# Use immutable references whenever possible.
|
||||
BACKEND_IMAGE=harbor.omniverseai.net/app/ljwx-platform:sha-CHANGE_ME
|
||||
ADMIN_IMAGE=harbor.omniverseai.net/app/ljwx-platform-admin-ui:sha-CHANGE_ME
|
||||
SCREEN_IMAGE=harbor.omniverseai.net/app/ljwx-platform-screen:sha-CHANGE_ME
|
||||
|
||||
# 1 to create/refresh ljwx-platform-db from this env file, 0 to reuse existing secret.
|
||||
MANAGE_DB_SECRET=1
|
||||
|
||||
# 1 to create/refresh image pull secret and patch default service account.
|
||||
MANAGE_PULL_SECRET=0
|
||||
REGISTRY_SERVER=harbor.omniverseai.net
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
|
||||
IMAGE_PULL_SECRET_NAME=harbor-pull
|
||||
ROLLOUT_TIMEOUT=300s
|
||||
@@ -20,6 +20,9 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.compose
|
||||
.env.delivery
|
||||
.env.k3s.delivery
|
||||
|
||||
# ── Logs ──
|
||||
logs/
|
||||
@@ -41,5 +44,8 @@ docs/reports/data/history/**
|
||||
docs/reports/data/artifacts/
|
||||
docs/reports/data/artifacts/**
|
||||
|
||||
# ── Local runtime temp ──
|
||||
.tmp/
|
||||
|
||||
# ── Local agent worktrees ──
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -95,7 +95,70 @@ pnpm dev
|
||||
|
||||
数据大屏默认访问 `http://localhost:5174`。
|
||||
|
||||
### 6. 运行 Gate 检查
|
||||
### 6. 一键 Docker Compose(推荐端到端验收)
|
||||
|
||||
```bash
|
||||
cp .env.compose.example .env.compose
|
||||
# 必须先修改 .env.compose 里的 DB_PASSWORD 为强口令
|
||||
bash scripts/local/compose-stack.sh up
|
||||
bash scripts/local/compose-stack.sh smoke
|
||||
```
|
||||
|
||||
默认端口:
|
||||
|
||||
- backend: `http://localhost:18080`
|
||||
- admin: `http://localhost:18081`
|
||||
- screen: `http://localhost:18082`
|
||||
|
||||
说明:`postgres/redis` 仅在 compose 内网可见,默认不暴露宿主机端口。
|
||||
|
||||
运行 R10 E2E 闭环:
|
||||
|
||||
```bash
|
||||
bash scripts/local/compose-stack.sh e2e
|
||||
```
|
||||
|
||||
停止并清理:
|
||||
|
||||
```bash
|
||||
bash scripts/local/compose-stack.sh down
|
||||
```
|
||||
|
||||
### 7. 一键 Docker Compose(纯交付件模式)
|
||||
|
||||
该模式只拉取镜像,不进行本地 `docker build`,用于验收“纯交付件部署”。
|
||||
|
||||
```bash
|
||||
cp .env.delivery.example .env.delivery
|
||||
# 必填:BACKEND_IMAGE / ADMIN_IMAGE / SCREEN_IMAGE(建议使用不可变 digest)
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh up
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh smoke
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh pull
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh e2e
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh down
|
||||
```
|
||||
|
||||
### 8. 本地 k3s 纯交付件部署(不依赖 Argo)
|
||||
|
||||
```bash
|
||||
cp .env.k3s.delivery.example .env.k3s.delivery
|
||||
# 必填:镜像引用、数据库口令;按需设置拉取凭据
|
||||
bash scripts/local/k3s-delivery.sh apply
|
||||
bash scripts/local/k3s-delivery.sh status
|
||||
```
|
||||
|
||||
- 清单位于:`deploy/k3s/artifact`
|
||||
- Ingress 参考模板:`deploy/k3s/artifact/ingress.example.yaml`
|
||||
- Secret 参考模板:`deploy/k3s/artifact/ljwx-platform-db.secret.example.yaml`
|
||||
- `MANAGE_DB_SECRET=1` 时脚本会根据 `.env.k3s.delivery` 创建/更新 `ljwx-platform-db`
|
||||
- `MANAGE_PULL_SECRET=1` 时脚本会创建镜像拉取 secret 并补丁 default service account
|
||||
|
||||
### 9. 运行 Gate 检查
|
||||
|
||||
```bash
|
||||
bash scripts/gates/gate-all.sh 27
|
||||
@@ -168,7 +231,12 @@ ljwx-platform/
|
||||
├── README.md # 本文件
|
||||
├── pom.xml # Maven 父 POM
|
||||
├── pnpm-workspace.yaml # pnpm 工作区配置
|
||||
├── docker-compose.yml # 本地开发环境
|
||||
├── docker-compose.yml # 本地开发环境(数据库占位)
|
||||
├── docker-compose.stack.yml # 源码构建 compose
|
||||
├── docker-compose.delivery.yml # 纯交付件 compose(仅镜像拉取)
|
||||
├── .env.compose.example # 源码构建 compose 环境模板
|
||||
├── .env.delivery.example # 纯交付件 compose 环境模板
|
||||
├── .env.k3s.delivery.example # k3s 纯交付件环境模板
|
||||
│
|
||||
├── ljwx-platform-core/ # 核心模块(接口 + 通用类)
|
||||
├── ljwx-platform-security/ # 安全模块(JWT + Spring Security)
|
||||
@@ -189,10 +257,13 @@ ljwx-platform/
|
||||
│
|
||||
├── spec/ # 项目规格文档
|
||||
│ └── phase/ # 各 Phase 任务说明
|
||||
├── deploy/
|
||||
│ └── k3s/artifact/ # k3s 纯交付件部署清单(backend/admin/screen)
|
||||
│
|
||||
└── scripts/
|
||||
├── gates/ # Gate 验收脚本
|
||||
├── hooks/ # Claude Code Hooks
|
||||
├── local/ # 本地 compose/k3s 快速部署脚本
|
||||
├── tools/ # 工具脚本
|
||||
└── acceptance/ # 验收测试脚本
|
||||
```
|
||||
@@ -283,6 +354,19 @@ ljwx-platform/
|
||||
- 目标 Deployment rollout 完成,且 `DEPLOYMENT_ID` 与 release values 一致
|
||||
- Prometheus active targets 中 `otel-agent-metrics` 为 `UP`
|
||||
|
||||
若只需要验证“交付件可直接落地 k3s”(不经过 Argo),可使用:
|
||||
|
||||
- `bash scripts/local/k3s-delivery.sh apply`
|
||||
- `bash scripts/local/k3s-delivery.sh status`
|
||||
|
||||
## Grafana 可观测看板(日志 + 链路 + 指标)
|
||||
|
||||
- 统一看板文件:`k8s/grafana-dashboard-observability.json`
|
||||
- 自动部署命令:
|
||||
- `bash scripts/ops/apply-grafana-observability-dashboard.sh`
|
||||
- 使用说明:
|
||||
- `docs/ops/grafana-observability-dashboard.md`
|
||||
|
||||
## 主分支自动修复闭环(Full Test + Auto Repair)
|
||||
|
||||
- 监控入口:`.github/workflows/main-branch-auto-repair.yml`
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api {
|
||||
proxy_pass http://backend:8080/api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api {
|
||||
proxy_pass http://backend:8080/api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ljwx-platform-admin-ui
|
||||
labels:
|
||||
app: ljwx-platform-admin-ui
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ljwx-platform-admin-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ljwx-platform-admin-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: ljwx-platform-admin-ui
|
||||
image: ghcr.io/brunogao/ljwx-platform/ljwx-platform-admin-ui:sha-replace-me
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
@@ -0,0 +1,47 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ljwx-platform
|
||||
labels:
|
||||
app: ljwx-platform
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ljwx-platform
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ljwx-platform
|
||||
spec:
|
||||
containers:
|
||||
- name: ljwx-platform
|
||||
image: ghcr.io/brunogao/ljwx-platform/ljwx-platform:sha-replace-me
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: ljwx-platform-runtime
|
||||
- secretRef:
|
||||
name: ljwx-platform-db
|
||||
volumeMounts:
|
||||
- name: app-logs
|
||||
mountPath: /workspace/logs
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: app-logs
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,36 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ljwx-platform-screen
|
||||
labels:
|
||||
app: ljwx-platform-screen
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ljwx-platform-screen
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ljwx-platform-screen
|
||||
spec:
|
||||
containers:
|
||||
- name: ljwx-platform-screen
|
||||
image: ghcr.io/brunogao/ljwx-platform/ljwx-platform-screen:sha-replace-me
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
@@ -0,0 +1,51 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ljwx-platform-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: dnspod-letsencrypt
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: <change-me-backend-host>
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ljwx-platform
|
||||
port:
|
||||
number: 80
|
||||
- host: <change-me-admin-host>
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ljwx-platform-admin-ui
|
||||
port:
|
||||
number: 80
|
||||
- host: <change-me-screen-host>
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ljwx-platform-screen
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- <change-me-backend-host>
|
||||
secretName: <change-me-backend-tls>
|
||||
- hosts:
|
||||
- <change-me-admin-host>
|
||||
secretName: <change-me-admin-tls>
|
||||
- hosts:
|
||||
- <change-me-screen-host>
|
||||
secretName: <change-me-screen-tls>
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment-backend.yaml
|
||||
- deployment-admin-ui.yaml
|
||||
- deployment-screen.yaml
|
||||
- service-backend.yaml
|
||||
- service-admin-ui.yaml
|
||||
- service-screen.yaml
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ljwx-platform-db
|
||||
type: Opaque
|
||||
stringData:
|
||||
DB_USERNAME: "<postgres_user>"
|
||||
DB_PASSWORD: "<postgres_password>"
|
||||
REDIS_PASSWORD: "<redis_password>"
|
||||
JWT_SECRET: "<jwt_secret_32_plus_chars>"
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ljwx-platform-admin-ui
|
||||
labels:
|
||||
app: ljwx-platform-admin-ui
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: ljwx-platform-admin-ui
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ljwx-platform
|
||||
labels:
|
||||
app: ljwx-platform
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: ljwx-platform
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ljwx-platform-screen
|
||||
labels:
|
||||
app: ljwx-platform-screen
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: ljwx-platform-screen
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,93 @@
|
||||
name: ljwx-platform-delivery
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16.12-alpine}
|
||||
container_name: ljwx-platform-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-ljwx_platform}
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- ljwx_platform_pg:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-ljwx_platform}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:-redis:7.2.5-alpine}
|
||||
container_name: ljwx-platform-redis
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE}
|
||||
container_name: ljwx-platform-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_NAME: ${DB_NAME:-ljwx_platform}
|
||||
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SERVER_PORT: 8080
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${BACKEND_PORT:-18080}:8080"
|
||||
|
||||
admin:
|
||||
image: ${ADMIN_IMAGE}
|
||||
container_name: ljwx-platform-admin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./deploy/compose/nginx/admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "${ADMIN_PORT:-18081}:80"
|
||||
|
||||
screen:
|
||||
image: ${SCREEN_IMAGE}
|
||||
container_name: ljwx-platform-screen
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./deploy/compose/nginx/screen.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "${SCREEN_PORT:-18082}:80"
|
||||
|
||||
volumes:
|
||||
ljwx_platform_pg:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ljwx-platform-stack-net
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 10.89.0.0/24
|
||||
@@ -0,0 +1,108 @@
|
||||
name: ljwx-platform-stack
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
container_name: ljwx-platform-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-ljwx_platform}
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- ljwx_platform_pg:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-ljwx_platform}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:-redis:7-alpine}
|
||||
container_name: ljwx-platform-redis
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ljwx-platform-app/Dockerfile
|
||||
image: ${BACKEND_IMAGE:-ljwx-platform-app:local}
|
||||
container_name: ljwx-platform-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-dev}
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_NAME: ${DB_NAME:-ljwx_platform}
|
||||
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SERVER_PORT: 8080
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${BACKEND_PORT:-18080}:8080"
|
||||
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ljwx-platform-admin/Dockerfile
|
||||
args:
|
||||
VITE_APP_BASE_API: ${ADMIN_API_BASE:-/api}
|
||||
VITE_APP_TITLE: ${ADMIN_APP_TITLE:-LJWX Platform Admin}
|
||||
image: ${ADMIN_IMAGE:-ljwx-platform-admin:local}
|
||||
container_name: ljwx-platform-admin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./deploy/compose/nginx/admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "${ADMIN_PORT:-18081}:80"
|
||||
|
||||
screen:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ljwx-platform-screen/Dockerfile
|
||||
args:
|
||||
VITE_APP_BASE_API: ${SCREEN_API_BASE:-/api}
|
||||
VITE_APP_TITLE: ${SCREEN_APP_TITLE:-LJWX Platform Screen}
|
||||
image: ${SCREEN_IMAGE:-ljwx-platform-screen:local}
|
||||
container_name: ljwx-platform-screen
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./deploy/compose/nginx/screen.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "${SCREEN_PORT:-18082}:80"
|
||||
|
||||
volumes:
|
||||
ljwx_platform_pg:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ljwx-platform-stack-net
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 10.89.0.0/24
|
||||
@@ -0,0 +1,68 @@
|
||||
# Grafana 可观测统一视图(日志 + 链路 + 指标)
|
||||
|
||||
本文档用于在 `ljwx-platform` 上快速启用并验证统一可观测看板。
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- Grafana 已部署且可访问
|
||||
- Prometheus / Loki / Tempo 数据源已在 Grafana 中可用
|
||||
- `ljwx-platform` 服务已接入 OTEL 与结构化日志
|
||||
|
||||
## 2. Dashboard 文件
|
||||
|
||||
- `k8s/grafana-dashboard-observability.json`
|
||||
|
||||
看板分区:
|
||||
|
||||
- 指标(Prometheus):QPS、错误率、P95、状态码趋势、接口延迟趋势
|
||||
- 日志(Loki):实时日志流、日志级别趋势、按 `trace_id` 过滤日志
|
||||
- 链路(Tempo):`service.name` 维度 Trace 搜索 + 与日志联动排查
|
||||
|
||||
## 3. 自动部署到 k8s(推荐)
|
||||
|
||||
```bash
|
||||
bash scripts/ops/apply-grafana-observability-dashboard.sh
|
||||
```
|
||||
|
||||
可选环境变量:
|
||||
|
||||
- `NAMESPACE`(默认 `monitoring`)
|
||||
- `CONFIGMAP_NAME`(默认 `ljwx-platform-observability-dashboard`)
|
||||
- `DASHBOARD_FILE`(默认 `k8s/grafana-dashboard-observability.json`)
|
||||
- `KEY_NAME`(默认 `ljwx-platform-observability.json`)
|
||||
|
||||
脚本会创建/更新 ConfigMap,并打上 `grafana_dashboard=1` 标签,兼容 Grafana Sidecar 自动加载。
|
||||
|
||||
## 4. 手工导入(兜底)
|
||||
|
||||
Grafana UI:
|
||||
|
||||
1. Dashboards -> Import
|
||||
2. 上传 `k8s/grafana-dashboard-observability.json`
|
||||
3. 绑定数据源变量:
|
||||
- `DS_PROMETHEUS`
|
||||
- `DS_LOKI`
|
||||
- `DS_TEMPO`
|
||||
|
||||
## 5. 使用方式(Trace 关联排障)
|
||||
|
||||
1. 从接口响应头获取 `X-Trace-Id`
|
||||
2. 在看板变量 `trace_id` 中粘贴该值
|
||||
3. 在「按 TraceId 过滤日志」面板查看关联日志
|
||||
4. 在「Trace 搜索」面板按同一 `service_name` 检索调用链
|
||||
|
||||
## 6. 验收命令
|
||||
|
||||
```bash
|
||||
# 1) 检查 ConfigMap
|
||||
kubectl get cm ljwx-platform-observability-dashboard -n monitoring
|
||||
|
||||
# 2) 检查 Loki 是否有日志
|
||||
kubectl -n monitoring logs deploy/loki --since=5m | tail -n 20
|
||||
|
||||
# 3) 检查 Tempo 是否就绪
|
||||
kubectl -n tracing get svc tempo
|
||||
|
||||
# 4) 触发一次后端健康检查,生成日志与指标样本
|
||||
curl -fsS http://platform-backend.lingjingwanxiang.cn/actuator/health
|
||||
```
|
||||
@@ -0,0 +1,810 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "指标 (Prometheus)",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"values": false,
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": ""
|
||||
},
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(rate(http_server_requests_seconds_count{application=\"$service_name\"}[5m]))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "请求量 (QPS)",
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 1
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"values": false,
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": ""
|
||||
},
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(rate(http_server_requests_seconds_count{application=\"$service_name\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{application=\"$service_name\"}[5m])) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "错误率 (%)",
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 1
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"values": false,
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": ""
|
||||
},
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{application=\"$service_name\"}[5m])) by (le))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "P95 延迟 (s)",
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 12,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 9
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"mean",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum by (status) (rate(http_server_requests_seconds_count{application=\"$service_name\"}[5m]))",
|
||||
"legendFormat": "{{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "HTTP 状态码速率",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 9
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"lastNotNull",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{application=\"$service_name\"}[5m])) by (le, uri))",
|
||||
"legendFormat": "{{uri}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "接口 P95 延迟 (Top URI)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 17
|
||||
},
|
||||
"id": 7,
|
||||
"panels": [],
|
||||
"title": "日志 (Loki)",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 18
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"expr": "{app=\"$service_name\"} | json",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "应用日志流",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 12,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 28
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"lastNotNull",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"expr": "sum by (level) (count_over_time({app=\"$service_name\"} | json | level=~\"ERROR|WARN|INFO\" [5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "日志级别趋势 (5m)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 28
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"expr": "{app=\"$service_name\"} |= \"$trace_id\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "按 TraceId 过滤日志 (填写上方变量 trace_id)",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 36
|
||||
},
|
||||
"id": 11,
|
||||
"panels": [],
|
||||
"title": "链路 (Tempo)",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "tempo",
|
||||
"uid": "${DS_TEMPO}"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 37
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"query": {
|
||||
"filters": [
|
||||
{
|
||||
"id": "service-name",
|
||||
"operator": "=",
|
||||
"scope": "resource",
|
||||
"tag": "service.name",
|
||||
"value": [
|
||||
"$service_name"
|
||||
],
|
||||
"valueType": "string"
|
||||
}
|
||||
],
|
||||
"limit": 20,
|
||||
"maxDuration": "",
|
||||
"minDuration": "",
|
||||
"query": "",
|
||||
"queryType": "traceql",
|
||||
"search": "",
|
||||
"serviceName": "$service_name",
|
||||
"spanName": ""
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "tempo",
|
||||
"uid": "${DS_TEMPO}"
|
||||
},
|
||||
"limit": 20,
|
||||
"query": "{service.name=\"$service_name\"}",
|
||||
"queryType": "traceql",
|
||||
"refId": "A",
|
||||
"search": "",
|
||||
"serviceName": "$service_name",
|
||||
"spanName": ""
|
||||
}
|
||||
],
|
||||
"title": "Trace 搜索",
|
||||
"type": "tempo-search"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 47
|
||||
},
|
||||
"id": 13,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "${DS_LOKI}"
|
||||
},
|
||||
"expr": "{app=\"$service_name\"} | json | traceId != \"\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "最近可关联 TraceId 的日志",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"ljwx",
|
||||
"observability",
|
||||
"metrics",
|
||||
"logs",
|
||||
"traces"
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "prometheus",
|
||||
"value": "prometheus"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Prometheus",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "loki",
|
||||
"value": "loki"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Loki",
|
||||
"multi": false,
|
||||
"name": "DS_LOKI",
|
||||
"options": [],
|
||||
"query": "loki",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "tempo",
|
||||
"value": "tempo"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Tempo",
|
||||
"multi": false,
|
||||
"name": "DS_TEMPO",
|
||||
"options": [],
|
||||
"query": "tempo",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "ljwx-platform",
|
||||
"value": "ljwx-platform"
|
||||
},
|
||||
"description": "OTel service.name 以及 Loki app label",
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Service",
|
||||
"multi": false,
|
||||
"name": "service_name",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "ljwx-platform",
|
||||
"value": "ljwx-platform"
|
||||
}
|
||||
],
|
||||
"query": "ljwx-platform",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "",
|
||||
"value": ""
|
||||
},
|
||||
"description": "可选:粘贴 X-Trace-Id 后查看关联日志",
|
||||
"hide": 0,
|
||||
"label": "Trace ID",
|
||||
"name": "trace_id",
|
||||
"query": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "LJWX Platform - 可观测统一视图",
|
||||
"uid": "ljwx-observability",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -7,6 +7,12 @@ RUN npm install -g pnpm@10.30.1 --quiet
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build-time frontend env vars for reusable compose/k8s pipelines.
|
||||
ARG VITE_APP_BASE_API=/api
|
||||
ARG VITE_APP_TITLE="LJWX Platform Admin"
|
||||
ENV VITE_APP_BASE_API=${VITE_APP_BASE_API}
|
||||
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
|
||||
# Copy workspace manifests first (layer-cache friendly)
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
|
||||
@@ -7,6 +7,12 @@ RUN npm install -g pnpm@10.30.1 --quiet
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build-time frontend env vars for reusable compose/k8s pipelines.
|
||||
ARG VITE_APP_BASE_API=/api
|
||||
ARG VITE_APP_TITLE="LJWX Platform Screen"
|
||||
ENV VITE_APP_BASE_API=${VITE_APP_BASE_API}
|
||||
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
|
||||
# Copy workspace manifests first (layer-cache friendly)
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
DB_NAME="${DB_NAME:-ljwx_platform}"
|
||||
DB_USERNAME="${DB_USERNAME:-postgres}"
|
||||
POSTGRES_IMAGE_FILTER="${POSTGRES_IMAGE_FILTER:-postgres:16-alpine}"
|
||||
POSTGRES_CONTAINER_ID="${POSTGRES_CONTAINER_ID:-}"
|
||||
|
||||
@@ -14,30 +15,43 @@ if [[ -z "${POSTGRES_CONTAINER_ID}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec -i "${POSTGRES_CONTAINER_ID}" psql -v ON_ERROR_STOP=1 -U postgres -d "${DB_NAME}" <<'SQL'
|
||||
-- Ensure tenant B exists.
|
||||
docker exec -i "${POSTGRES_CONTAINER_ID}" psql -v ON_ERROR_STOP=1 -U "${DB_USERNAME}" -d "${DB_NAME}" <<'SQL'
|
||||
-- Ensure tenant B exists with tenant_id=200001, and keep seeded entities aligned.
|
||||
INSERT INTO sys_tenant (id, name, code, status, tenant_id, created_by, updated_by, version)
|
||||
VALUES (200001, 'Tenant B', 'tenant_b', 1, 0, 0, 0, 1)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
id = EXCLUDED.id,
|
||||
code = EXCLUDED.code,
|
||||
status = EXCLUDED.status,
|
||||
deleted = FALSE,
|
||||
updated_time = CURRENT_TIMESTAMP;
|
||||
|
||||
-- Fix previously seeded records that used tenant_id=2.
|
||||
UPDATE sys_permission SET tenant_id = 200001 WHERE id IN (220024, 220025, 220026, 220027, 220028);
|
||||
UPDATE sys_role SET tenant_id = 200001 WHERE id = 220001;
|
||||
UPDATE sys_user SET tenant_id = 200001 WHERE id = 220002;
|
||||
UPDATE sys_role_permission SET tenant_id = 200001 WHERE role_id = 220001;
|
||||
UPDATE sys_user_role SET tenant_id = 200001 WHERE user_id = 220002;
|
||||
|
||||
-- Ensure tenant-B menu permissions exist.
|
||||
INSERT INTO sys_permission (id, tenant_id, code, name, created_by, updated_by, version) VALUES
|
||||
(220024, 2, 'system:menu:list', '菜单查询', 0, 0, 1),
|
||||
(220025, 2, 'system:menu:detail', '菜单详情', 0, 0, 1),
|
||||
(220026, 2, 'system:menu:create', '菜单新增', 0, 0, 1),
|
||||
(220027, 2, 'system:menu:update', '菜单修改', 0, 0, 1),
|
||||
(220028, 2, 'system:menu:delete', '菜单删除', 0, 0, 1)
|
||||
(220024, 200001, 'system:menu:list', '菜单查询', 0, 0, 1),
|
||||
(220025, 200001, 'system:menu:detail', '菜单详情', 0, 0, 1),
|
||||
(220026, 200001, 'system:menu:create', '菜单新增', 0, 0, 1),
|
||||
(220027, 200001, 'system:menu:update', '菜单修改', 0, 0, 1),
|
||||
(220028, 200001, 'system:menu:delete', '菜单删除', 0, 0, 1)
|
||||
ON CONFLICT (tenant_id, code) DO NOTHING;
|
||||
|
||||
-- Ensure tenant-B role exists.
|
||||
INSERT INTO sys_role (id, tenant_id, name, code, status, created_by, updated_by, version)
|
||||
VALUES (220001, 2, 'Tenant B Menu Operator', 'TENANT_B_MENU_OPERATOR', 1, 0, 0, 1)
|
||||
VALUES (220001, 200001, 'Tenant B Menu Operator', 'TENANT_B_MENU_OPERATOR', 1, 0, 0, 1)
|
||||
ON CONFLICT (tenant_id, code) DO NOTHING;
|
||||
|
||||
-- Ensure tenant-B admin test account exists.
|
||||
-- Password hash corresponds to plain text: Admin@12345
|
||||
INSERT INTO sys_user (id, tenant_id, username, password, nickname, status, created_by, updated_by, version)
|
||||
VALUES (220002, 2, 'tenantB_admin', '$2a$10$PnWlMR8Ox6UMTZj7Zm9uO.wSqzbjVt04UbeJ7q3RxDe8TSIP6efz2', 'Tenant B Admin', 1, 0, 0, 1)
|
||||
VALUES (220002, 200001, 'tenantB_admin', '$2a$10$PnWlMR8Ox6UMTZj7Zm9uO.wSqzbjVt04UbeJ7q3RxDe8TSIP6efz2', 'Tenant B Admin', 1, 0, 0, 1)
|
||||
ON CONFLICT (tenant_id, username) DO UPDATE
|
||||
SET password = EXCLUDED.password,
|
||||
status = 1,
|
||||
@@ -48,12 +62,12 @@ SET password = EXCLUDED.password,
|
||||
WITH role_row AS (
|
||||
SELECT id AS role_id
|
||||
FROM sys_role
|
||||
WHERE tenant_id = 2 AND code = 'TENANT_B_MENU_OPERATOR'
|
||||
WHERE tenant_id = 200001 AND code = 'TENANT_B_MENU_OPERATOR'
|
||||
),
|
||||
perm_rows AS (
|
||||
SELECT id AS permission_id
|
||||
FROM sys_permission
|
||||
WHERE tenant_id = 2
|
||||
WHERE tenant_id = 200001
|
||||
AND code IN (
|
||||
'system:menu:list',
|
||||
'system:menu:detail',
|
||||
@@ -72,13 +86,13 @@ base AS (
|
||||
SELECT COALESCE(MAX(id), 320000) AS max_id FROM sys_role_permission
|
||||
)
|
||||
INSERT INTO sys_role_permission (id, tenant_id, role_id, permission_id, created_by, updated_by, version)
|
||||
SELECT base.max_id + pairs.rn, 2, pairs.role_id, pairs.permission_id, 0, 0, 1
|
||||
SELECT base.max_id + pairs.rn, 200001, pairs.role_id, pairs.permission_id, 0, 0, 1
|
||||
FROM pairs
|
||||
CROSS JOIN base
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_permission rp
|
||||
WHERE rp.tenant_id = 2
|
||||
WHERE rp.tenant_id = 200001
|
||||
AND rp.role_id = pairs.role_id
|
||||
AND rp.permission_id = pairs.permission_id
|
||||
AND rp.deleted = FALSE
|
||||
@@ -88,23 +102,23 @@ WHERE NOT EXISTS (
|
||||
WITH role_row AS (
|
||||
SELECT id AS role_id
|
||||
FROM sys_role
|
||||
WHERE tenant_id = 2 AND code = 'TENANT_B_MENU_OPERATOR'
|
||||
WHERE tenant_id = 200001 AND code = 'TENANT_B_MENU_OPERATOR'
|
||||
),
|
||||
user_row AS (
|
||||
SELECT id AS user_id
|
||||
FROM sys_user
|
||||
WHERE tenant_id = 2 AND username = 'tenantB_admin'
|
||||
WHERE tenant_id = 200001 AND username = 'tenantB_admin'
|
||||
),
|
||||
base AS (
|
||||
SELECT COALESCE(MAX(id), 330000) AS max_id FROM sys_user_role
|
||||
)
|
||||
INSERT INTO sys_user_role (id, tenant_id, user_id, role_id, created_by, updated_by, version)
|
||||
SELECT base.max_id + 1, 2, user_row.user_id, role_row.role_id, 0, 0, 1
|
||||
SELECT base.max_id + 1, 200001, user_row.user_id, role_row.role_id, 0, 0, 1
|
||||
FROM role_row, user_row, base
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_role ur
|
||||
WHERE ur.tenant_id = 2
|
||||
WHERE ur.tenant_id = 200001
|
||||
AND ur.user_id = user_row.user_id
|
||||
AND ur.role_id = role_row.role_id
|
||||
AND ur.deleted = FALSE
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
TMP_DIR="${TMP_DIR:-/tmp/ljwx-gate-results}"
|
||||
mkdir -p "$TMP_DIR"
|
||||
chmod 777 "$TMP_DIR"
|
||||
OUT_JSON="$TMP_DIR/R10.json"
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
@@ -12,19 +13,33 @@ if ! command -v jq >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
run_k6() {
|
||||
if command -v k6 >/dev/null 2>&1; then
|
||||
if [[ "${K6_FORCE_DOCKER:-0}" != "1" ]] && command -v k6 >/dev/null 2>&1; then
|
||||
k6 "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
local network_args=()
|
||||
if [[ "$(uname -s)" == "Linux" ]]; then
|
||||
local env_args=()
|
||||
if [[ -n "${K6_DOCKER_NETWORK:-}" ]]; then
|
||||
network_args+=(--network "${K6_DOCKER_NETWORK}")
|
||||
elif [[ "$(uname -s)" == "Linux" ]]; then
|
||||
network_args+=(--network host)
|
||||
fi
|
||||
|
||||
local env_key
|
||||
for env_key in \
|
||||
BASE_URL LOGIN_PATH PROTECTED_PATH FORBIDDEN_PATH OK_PATH RESOURCE_BASE RESOURCE_LIST_PATH \
|
||||
TENANT_A_USER TENANT_A_PASS TENANT_B_USER TENANT_B_PASS \
|
||||
EXPECT_R_WRAPPER K6_VUS K6_ITERATIONS; do
|
||||
if [[ -n "${!env_key:-}" ]]; then
|
||||
env_args+=(-e "${env_key}")
|
||||
fi
|
||||
done
|
||||
|
||||
docker run --rm -i \
|
||||
"${network_args[@]}" \
|
||||
"${env_args[@]}" \
|
||||
-v "$PROJECT_ROOT:/work" \
|
||||
-v /tmp:/tmp \
|
||||
-w /work \
|
||||
@@ -129,10 +144,15 @@ run_case() {
|
||||
local id="$1"
|
||||
local script="$2"
|
||||
|
||||
local summary_file raw_file log_file
|
||||
summary_file="$(mktemp)"
|
||||
raw_file="$(mktemp)"
|
||||
log_file="$(mktemp)"
|
||||
local case_tmp_dir summary_file raw_file log_file
|
||||
case_tmp_dir="${TMP_DIR}/k6"
|
||||
mkdir -p "$case_tmp_dir"
|
||||
chmod 777 "$case_tmp_dir"
|
||||
|
||||
summary_file="${case_tmp_dir}/${id}.summary.$$.json"
|
||||
raw_file="${case_tmp_dir}/${id}.raw.$$.json"
|
||||
log_file="$(mktemp "${case_tmp_dir}/${id}.log.XXXXXX")"
|
||||
rm -f "$summary_file" "$raw_file"
|
||||
|
||||
local rc=0
|
||||
set +e
|
||||
@@ -144,10 +164,10 @@ run_case() {
|
||||
set -e
|
||||
|
||||
local checks_total checks_passed checks_failed p95
|
||||
checks_passed="$(read_json_number_or_zero "$summary_file" '(.metrics.checks.values.passes // 0) | tonumber? // 0 | floor')"
|
||||
checks_failed="$(read_json_number_or_zero "$summary_file" '(.metrics.checks.values.fails // 0) | tonumber? // 0 | floor')"
|
||||
checks_passed="$(read_json_number_or_zero "$summary_file" '(.metrics.checks.values.passes // .metrics.checks.passes // 0) | tonumber? // 0 | floor')"
|
||||
checks_failed="$(read_json_number_or_zero "$summary_file" '(.metrics.checks.values.fails // .metrics.checks.fails // 0) | tonumber? // 0 | floor')"
|
||||
checks_total=$((checks_passed + checks_failed))
|
||||
p95="$(read_json_number_or_zero "$summary_file" '(.metrics.http_req_duration.values["p(95)"] // 0) | tonumber? // 0')"
|
||||
p95="$(read_json_number_or_zero "$summary_file" '(.metrics.http_req_duration.values["p(95)"] // .metrics.http_req_duration["p(95)"] // 0) | tonumber? // 0')"
|
||||
|
||||
local v_checks v_logs
|
||||
v_checks="$(build_violations_from_checks "$summary_file" "$script")"
|
||||
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
RAW_CMD="${1:-}"
|
||||
DEPLOY_MODE="${DEPLOY_MODE:-source}"
|
||||
CMD="${RAW_CMD}"
|
||||
|
||||
case "${RAW_CMD}" in
|
||||
up-source)
|
||||
DEPLOY_MODE="source"
|
||||
CMD="up"
|
||||
;;
|
||||
restart-source)
|
||||
DEPLOY_MODE="source"
|
||||
CMD="restart"
|
||||
;;
|
||||
up-delivery)
|
||||
DEPLOY_MODE="delivery"
|
||||
CMD="up"
|
||||
;;
|
||||
restart-delivery)
|
||||
DEPLOY_MODE="delivery"
|
||||
CMD="restart"
|
||||
;;
|
||||
pull-delivery)
|
||||
DEPLOY_MODE="delivery"
|
||||
CMD="pull"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${DEPLOY_MODE}" != "source" && "${DEPLOY_MODE}" != "delivery" ]]; then
|
||||
echo "DEPLOY_MODE 非法: ${DEPLOY_MODE},仅支持 source 或 delivery" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEFAULT_COMPOSE_FILE="$PROJECT_ROOT/docker-compose.stack.yml"
|
||||
DEFAULT_ENV_FILE="$PROJECT_ROOT/.env.compose"
|
||||
DEFAULT_ENV_EXAMPLE="$PROJECT_ROOT/.env.compose.example"
|
||||
|
||||
if [[ "${DEPLOY_MODE}" == "delivery" ]]; then
|
||||
DEFAULT_COMPOSE_FILE="$PROJECT_ROOT/docker-compose.delivery.yml"
|
||||
DEFAULT_ENV_FILE="$PROJECT_ROOT/.env.delivery"
|
||||
DEFAULT_ENV_EXAMPLE="$PROJECT_ROOT/.env.delivery.example"
|
||||
fi
|
||||
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-$DEFAULT_COMPOSE_FILE}"
|
||||
ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}"
|
||||
CURL_IMAGE="${CURL_IMAGE:-curlimages/curl:8.7.1}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$DEFAULT_ENV_EXAMPLE" "$ENV_FILE"
|
||||
echo "已创建 $ENV_FILE(可按需调整端口和账号)"
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${DB_NAME:=ljwx_platform}"
|
||||
: "${DB_PASSWORD:=}"
|
||||
: "${ALLOW_WEAK_PASSWORD:=0}"
|
||||
: "${BACKEND_PORT:=18080}"
|
||||
: "${ADMIN_PORT:=18081}"
|
||||
: "${SCREEN_PORT:=18082}"
|
||||
: "${COMPOSE_NETWORK:=ljwx-platform-stack-net}"
|
||||
: "${BACKEND_IMAGE:=}"
|
||||
: "${ADMIN_IMAGE:=}"
|
||||
: "${SCREEN_IMAGE:=}"
|
||||
|
||||
should_validate_password="0"
|
||||
case "${CMD}" in
|
||||
up|restart|smoke|seed|e2e)
|
||||
should_validate_password="1"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${should_validate_password}" == "1" && "$ALLOW_WEAK_PASSWORD" != "1" ]]; then
|
||||
if [[ -z "$DB_PASSWORD" || "$DB_PASSWORD" == "postgres" || "$DB_PASSWORD" == "123456" || "$DB_PASSWORD" == *"CHANGE_ME"* || ${#DB_PASSWORD} -lt 12 ]]; then
|
||||
echo "检测到数据库密码不安全(为空/默认/过短)。请在 ${ENV_FILE} 设置强密码,或临时设置 ALLOW_WEAK_PASSWORD=1(不推荐)。" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
validate_delivery_images() {
|
||||
local missing=0
|
||||
for image_var in BACKEND_IMAGE ADMIN_IMAGE SCREEN_IMAGE; do
|
||||
local image_ref
|
||||
image_ref="${!image_var:-}"
|
||||
if [[ -z "${image_ref}" || "${image_ref}" == *"CHANGE_ME"* ]]; then
|
||||
echo "交付模式缺少有效镜像引用: ${image_var}。请在 ${ENV_FILE} 设置不可变 tag 或 digest。" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${missing}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
compose_up() {
|
||||
if [[ "${DEPLOY_MODE}" == "delivery" ]]; then
|
||||
validate_delivery_images
|
||||
compose pull
|
||||
compose up -d --no-build
|
||||
return
|
||||
fi
|
||||
compose up -d --build
|
||||
}
|
||||
|
||||
compose_restart() {
|
||||
if [[ "${DEPLOY_MODE}" == "delivery" ]]; then
|
||||
validate_delivery_images
|
||||
compose pull
|
||||
compose up -d --no-build --force-recreate
|
||||
return
|
||||
fi
|
||||
compose up -d --build --force-recreate
|
||||
}
|
||||
|
||||
http_probe() {
|
||||
local url="$1"
|
||||
docker run --rm --network "$COMPOSE_NETWORK" "$CURL_IMAGE" -fsS "$url" >/dev/null
|
||||
}
|
||||
|
||||
wait_http() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local retries="${3:-120}"
|
||||
local delay="${4:-2}"
|
||||
local i
|
||||
|
||||
for ((i = 1; i <= retries; i += 1)); do
|
||||
if http_probe "$url"; then
|
||||
echo "$name 已就绪: $url"
|
||||
return 0
|
||||
fi
|
||||
sleep "$delay"
|
||||
done
|
||||
|
||||
echo "$name 健康检查超时: $url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
seed_fixtures() {
|
||||
local postgres_container_id
|
||||
postgres_container_id="$(compose ps -q postgres)"
|
||||
if [[ -z "$postgres_container_id" ]]; then
|
||||
echo "未找到 postgres 容器,请先执行 up" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
POSTGRES_CONTAINER_ID="$postgres_container_id" DB_NAME="$DB_NAME" DB_USERNAME="${DB_USERNAME:-postgres}" \
|
||||
bash "$PROJECT_ROOT/scripts/e2e/seed-fixtures.sh"
|
||||
}
|
||||
|
||||
run_e2e() {
|
||||
if [[ "${DEPLOY_MODE}" == "delivery" ]]; then
|
||||
validate_delivery_images
|
||||
fi
|
||||
seed_fixtures
|
||||
K6_FORCE_DOCKER="${K6_FORCE_DOCKER:-1}" \
|
||||
K6_DOCKER_NETWORK="${K6_DOCKER_NETWORK:-$COMPOSE_NETWORK}" \
|
||||
BASE_URL="${BASE_URL:-http://backend:8080}" \
|
||||
TENANT_A_USER="${TENANT_A_USER:-admin}" \
|
||||
TENANT_A_PASS="${TENANT_A_PASS:-Admin@12345}" \
|
||||
TENANT_B_USER="${TENANT_B_USER:-tenantB_admin}" \
|
||||
TENANT_B_PASS="${TENANT_B_PASS:-Admin@12345}" \
|
||||
LOGIN_PATH="${LOGIN_PATH:-/api/auth/login}" \
|
||||
FORBIDDEN_PATH="${FORBIDDEN_PATH:-/api/users}" \
|
||||
RESOURCE_LIST_PATH="${RESOURCE_LIST_PATH:-/api/v1/menus}" \
|
||||
bash "$PROJECT_ROOT/scripts/gates/gate-e2e.sh"
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
up)
|
||||
compose_up
|
||||
;;
|
||||
down)
|
||||
compose down -v
|
||||
;;
|
||||
restart)
|
||||
compose_restart
|
||||
;;
|
||||
pull)
|
||||
if [[ "${DEPLOY_MODE}" != "delivery" ]]; then
|
||||
echo "pull 仅用于交付模式。请设置 DEPLOY_MODE=delivery 或使用 pull-delivery。" >&2
|
||||
exit 1
|
||||
fi
|
||||
validate_delivery_images
|
||||
compose pull
|
||||
;;
|
||||
ps|status)
|
||||
compose ps
|
||||
;;
|
||||
logs)
|
||||
compose logs -f --tail 200
|
||||
;;
|
||||
smoke)
|
||||
wait_http "backend" "http://backend:8080/actuator/health" 180 2
|
||||
wait_http "admin" "http://admin/" 60 2
|
||||
wait_http "screen" "http://screen/" 60 2
|
||||
;;
|
||||
seed)
|
||||
seed_fixtures
|
||||
;;
|
||||
e2e)
|
||||
run_e2e
|
||||
;;
|
||||
*)
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
# 源码构建模式(默认)
|
||||
bash scripts/local/compose-stack.sh up # 构建并启动 5 个服务
|
||||
bash scripts/local/compose-stack.sh restart
|
||||
|
||||
# 交付件模式(仅镜像拉取,不本地构建)
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh up
|
||||
DEPLOY_MODE=delivery bash scripts/local/compose-stack.sh pull
|
||||
bash scripts/local/compose-stack.sh up-delivery
|
||||
bash scripts/local/compose-stack.sh restart-delivery
|
||||
|
||||
# 通用操作
|
||||
bash scripts/local/compose-stack.sh smoke # 健康检查 backend/admin/screen
|
||||
bash scripts/local/compose-stack.sh seed # 写入 e2e 夹具数据
|
||||
bash scripts/local/compose-stack.sh e2e # 执行 R10 e2e 闭环
|
||||
bash scripts/local/compose-stack.sh status # 查看状态
|
||||
bash scripts/local/compose-stack.sh logs # 查看日志
|
||||
bash scripts/local/compose-stack.sh down # 停止并清理卷
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
KUSTOMIZE_DIR="${KUSTOMIZE_DIR:-$PROJECT_ROOT/deploy/k3s/artifact}"
|
||||
ENV_FILE="${ENV_FILE:-$PROJECT_ROOT/.env.k3s.delivery}"
|
||||
EXAMPLE_ENV_FILE="$PROJECT_ROOT/.env.k3s.delivery.example"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
cp "${EXAMPLE_ENV_FILE}" "${ENV_FILE}"
|
||||
echo "已创建 ${ENV_FILE},请先填写镜像和凭据后重试。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ENV_FILE}"
|
||||
set +a
|
||||
|
||||
: "${NAMESPACE:=ljwx-platform}"
|
||||
: "${RUNTIME_CONFIG_NAME:=ljwx-platform-runtime}"
|
||||
: "${DB_SECRET_NAME:=ljwx-platform-db}"
|
||||
: "${IMAGE_PULL_SECRET_NAME:=harbor-pull}"
|
||||
: "${SPRING_PROFILES_ACTIVE:=prod}"
|
||||
: "${DB_HOST:=postgres-lb.infra.svc.cluster.local}"
|
||||
: "${DB_PORT:=5432}"
|
||||
: "${DB_NAME:=ljwx_platform}"
|
||||
: "${REDIS_HOST:=redis-lb.infra.svc.cluster.local}"
|
||||
: "${REDIS_PORT:=6379}"
|
||||
: "${DB_USERNAME:=postgres}"
|
||||
: "${DB_PASSWORD:=}"
|
||||
: "${REDIS_PASSWORD:=}"
|
||||
: "${JWT_SECRET:=}"
|
||||
: "${BACKEND_IMAGE:=}"
|
||||
: "${ADMIN_IMAGE:=}"
|
||||
: "${SCREEN_IMAGE:=}"
|
||||
: "${MANAGE_DB_SECRET:=1}"
|
||||
: "${MANAGE_PULL_SECRET:=0}"
|
||||
: "${REGISTRY_SERVER:=}"
|
||||
: "${REGISTRY_USERNAME:=}"
|
||||
: "${REGISTRY_PASSWORD:=}"
|
||||
: "${ROLLOUT_TIMEOUT:=300s}"
|
||||
|
||||
require_command() {
|
||||
local cmd="$1"
|
||||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||||
echo "缺少依赖命令: ${cmd}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_images() {
|
||||
local missing=0
|
||||
for image_var in BACKEND_IMAGE ADMIN_IMAGE SCREEN_IMAGE; do
|
||||
local image_ref
|
||||
image_ref="${!image_var:-}"
|
||||
if [[ -z "${image_ref}" || "${image_ref}" == *"CHANGE_ME"* ]]; then
|
||||
echo "缺少有效镜像引用: ${image_var},请在 ${ENV_FILE} 中设置。" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${missing}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_db_secret_inputs() {
|
||||
if [[ "${MANAGE_DB_SECRET}" != "1" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "${DB_PASSWORD}" || "${DB_PASSWORD}" == *"CHANGE_ME"* || ${#DB_PASSWORD} -lt 12 ]]; then
|
||||
echo "DB_PASSWORD 不安全或未设置,请在 ${ENV_FILE} 中配置强口令。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${JWT_SECRET}" || "${JWT_SECRET}" == *"CHANGE_ME"* || ${#JWT_SECRET} -lt 32 ]]; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
JWT_SECRET="$(openssl rand -hex 32)"
|
||||
echo "检测到 JWT_SECRET 未设置,已生成临时随机值并用于本次部署。"
|
||||
else
|
||||
echo "JWT_SECRET 未设置且未安装 openssl,无法自动生成。" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_namespace() {
|
||||
kubectl get namespace "${NAMESPACE}" >/dev/null 2>&1 || kubectl create namespace "${NAMESPACE}" >/dev/null
|
||||
}
|
||||
|
||||
apply_runtime_config() {
|
||||
kubectl -n "${NAMESPACE}" create configmap "${RUNTIME_CONFIG_NAME}" \
|
||||
--from-literal=SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE}" \
|
||||
--from-literal=SPRING_FLYWAY_OUT_OF_ORDER=false \
|
||||
--from-literal=SPRING_FLYWAY_IGNORE_MIGRATION_PATTERNS='*:future,*:ignored' \
|
||||
--from-literal=DB_HOST="${DB_HOST}" \
|
||||
--from-literal=DB_PORT="${DB_PORT}" \
|
||||
--from-literal=DB_NAME="${DB_NAME}" \
|
||||
--from-literal=REDIS_HOST="${REDIS_HOST}" \
|
||||
--from-literal=REDIS_PORT="${REDIS_PORT}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
}
|
||||
|
||||
apply_db_secret() {
|
||||
if [[ "${MANAGE_DB_SECRET}" != "1" ]]; then
|
||||
if ! kubectl -n "${NAMESPACE}" get secret "${DB_SECRET_NAME}" >/dev/null 2>&1; then
|
||||
echo "MANAGE_DB_SECRET=0 且缺少 secret/${DB_SECRET_NAME},无法继续部署。" >&2
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
kubectl -n "${NAMESPACE}" create secret generic "${DB_SECRET_NAME}" \
|
||||
--from-literal=DB_USERNAME="${DB_USERNAME}" \
|
||||
--from-literal=DB_PASSWORD="${DB_PASSWORD}" \
|
||||
--from-literal=REDIS_PASSWORD="${REDIS_PASSWORD}" \
|
||||
--from-literal=JWT_SECRET="${JWT_SECRET}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
}
|
||||
|
||||
apply_pull_secret() {
|
||||
if [[ "${MANAGE_PULL_SECRET}" != "1" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local registry_host
|
||||
registry_host="${REGISTRY_SERVER}"
|
||||
if [[ -z "${registry_host}" ]]; then
|
||||
registry_host="${BACKEND_IMAGE%%/*}"
|
||||
fi
|
||||
|
||||
if [[ -z "${registry_host}" || -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_PASSWORD}" ]]; then
|
||||
echo "MANAGE_PULL_SECRET=1 需要 REGISTRY_SERVER/REGISTRY_USERNAME/REGISTRY_PASSWORD。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kubectl -n "${NAMESPACE}" create secret docker-registry "${IMAGE_PULL_SECRET_NAME}" \
|
||||
--docker-server="${registry_host}" \
|
||||
--docker-username="${REGISTRY_USERNAME}" \
|
||||
--docker-password="${REGISTRY_PASSWORD}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl -n "${NAMESPACE}" patch serviceaccount default \
|
||||
--type=merge \
|
||||
-p "{\"imagePullSecrets\":[{\"name\":\"${IMAGE_PULL_SECRET_NAME}\"}]}"
|
||||
}
|
||||
|
||||
apply_workloads() {
|
||||
kubectl -n "${NAMESPACE}" apply -k "${KUSTOMIZE_DIR}"
|
||||
|
||||
kubectl -n "${NAMESPACE}" set image deployment/ljwx-platform \
|
||||
ljwx-platform="${BACKEND_IMAGE}"
|
||||
kubectl -n "${NAMESPACE}" set image deployment/ljwx-platform-admin-ui \
|
||||
ljwx-platform-admin-ui="${ADMIN_IMAGE}"
|
||||
kubectl -n "${NAMESPACE}" set image deployment/ljwx-platform-screen \
|
||||
ljwx-platform-screen="${SCREEN_IMAGE}"
|
||||
|
||||
kubectl -n "${NAMESPACE}" rollout status deployment/ljwx-platform --timeout="${ROLLOUT_TIMEOUT}"
|
||||
kubectl -n "${NAMESPACE}" rollout status deployment/ljwx-platform-admin-ui --timeout="${ROLLOUT_TIMEOUT}"
|
||||
kubectl -n "${NAMESPACE}" rollout status deployment/ljwx-platform-screen --timeout="${ROLLOUT_TIMEOUT}"
|
||||
}
|
||||
|
||||
print_status() {
|
||||
kubectl -n "${NAMESPACE}" get deploy,svc,pods -o wide
|
||||
}
|
||||
|
||||
print_logs() {
|
||||
kubectl -n "${NAMESPACE}" logs deploy/ljwx-platform --tail=200
|
||||
}
|
||||
|
||||
run_apply() {
|
||||
require_command kubectl
|
||||
validate_images
|
||||
validate_db_secret_inputs
|
||||
ensure_namespace
|
||||
apply_runtime_config
|
||||
apply_db_secret
|
||||
apply_pull_secret
|
||||
apply_workloads
|
||||
print_status
|
||||
}
|
||||
|
||||
CMD="${1:-}"
|
||||
case "${CMD}" in
|
||||
apply)
|
||||
run_apply
|
||||
;;
|
||||
status)
|
||||
require_command kubectl
|
||||
print_status
|
||||
;;
|
||||
logs)
|
||||
require_command kubectl
|
||||
print_logs
|
||||
;;
|
||||
render)
|
||||
require_command kubectl
|
||||
kubectl kustomize "${KUSTOMIZE_DIR}"
|
||||
;;
|
||||
delete)
|
||||
require_command kubectl
|
||||
kubectl delete namespace "${NAMESPACE}" --ignore-not-found
|
||||
;;
|
||||
*)
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
bash scripts/local/k3s-delivery.sh apply # 按交付镜像部署 backend/admin-ui/screen
|
||||
bash scripts/local/k3s-delivery.sh status # 查看部署状态
|
||||
bash scripts/local/k3s-delivery.sh logs # 查看 backend 日志
|
||||
bash scripts/local/k3s-delivery.sh render # 渲染 kustomize 清单
|
||||
bash scripts/local/k3s-delivery.sh delete # 删除命名空间(慎用)
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
NAMESPACE="${NAMESPACE:-monitoring}"
|
||||
CONFIGMAP_NAME="${CONFIGMAP_NAME:-ljwx-platform-observability-dashboard}"
|
||||
DASHBOARD_FILE="${DASHBOARD_FILE:-${PROJECT_ROOT}/k8s/grafana-dashboard-observability.json}"
|
||||
KEY_NAME="${KEY_NAME:-ljwx-platform-observability.json}"
|
||||
|
||||
if [ ! -f "${DASHBOARD_FILE}" ]; then
|
||||
echo "[致命] 仪表盘文件不存在: ${DASHBOARD_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq empty "${DASHBOARD_FILE}" >/dev/null
|
||||
else
|
||||
echo "[警告] 未安装 jq,跳过 JSON 语法校验"
|
||||
fi
|
||||
|
||||
echo "[信息] 应用 Grafana Dashboard ConfigMap: ${NAMESPACE}/${CONFIGMAP_NAME}"
|
||||
kubectl create configmap "${CONFIGMAP_NAME}" \
|
||||
-n "${NAMESPACE}" \
|
||||
--from-file="${KEY_NAME}=${DASHBOARD_FILE}" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
kubectl label configmap "${CONFIGMAP_NAME}" -n "${NAMESPACE}" grafana_dashboard=1 --overwrite >/dev/null
|
||||
kubectl label configmap "${CONFIGMAP_NAME}" -n "${NAMESPACE}" app.kubernetes.io/name=ljwx-platform --overwrite >/dev/null
|
||||
kubectl label configmap "${CONFIGMAP_NAME}" -n "${NAMESPACE}" app.kubernetes.io/component=observability --overwrite >/dev/null
|
||||
|
||||
echo "[完成] Dashboard ConfigMap 已更新"
|
||||
echo "[检查] kubectl get cm ${CONFIGMAP_NAME} -n ${NAMESPACE} -o yaml | head -n 40"
|
||||
echo "[检查] kubectl get pods -n ${NAMESPACE} -l app.kubernetes.io/name=grafana"
|
||||
echo "[提示] 如 Grafana 未自动加载,请在 UI -> Dashboards -> Import 导入文件: ${DASHBOARD_FILE}"
|
||||
+1
-1
@@ -20,7 +20,7 @@ export const cfg = {
|
||||
|
||||
protectedPath: __ENV.PROTECTED_PATH || "/api/v1/menus/tree",
|
||||
forbiddenPath: __ENV.FORBIDDEN_PATH || "/api/users",
|
||||
okPath: __ENV.OK_PATH || "/api/v1/menus/tree",
|
||||
okPath: __ENV.OK_PATH || "/api/v1/menus",
|
||||
expectRWrapper: (__ENV.EXPECT_R_WRAPPER || "true").toLowerCase() === "true",
|
||||
|
||||
resourceBase: __ENV.RESOURCE_BASE || "/api/v1/menus",
|
||||
|
||||
@@ -21,13 +21,14 @@ function getPathValue(root, path) {
|
||||
|
||||
function extractId(payload) {
|
||||
const candidates = [
|
||||
getPathValue(payload, ["data"]),
|
||||
getPathValue(payload, ["data", "id"]),
|
||||
getPathValue(payload, ["data", "record", "id"]),
|
||||
getPathValue(payload, ["data", 0, "id"]),
|
||||
getPathValue(payload, ["id"]),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== undefined && candidate !== null && candidate !== "") {
|
||||
if ((typeof candidate === "string" || typeof candidate === "number") && candidate !== "") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user