feat(delivery): add compose and k3s artifact deployment flow

This commit is contained in:
BrunoGaoSZ
2026-03-07 14:50:16 +08:00
committed by BrunoGao
parent 4021ccadc5
commit 264862fd63
29 changed files with 2092 additions and 31 deletions
+16
View File
@@ -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"
+20
View File
@@ -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
+30
View File
@@ -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
+6
View File
@@ -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/
+86 -2
View File
@@ -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`
+28
View File
@@ -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;
}
}
+28
View File
@@ -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
+51
View File
@@ -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>
+9
View File
@@ -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>"
+15
View File
@@ -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
+15
View File
@@ -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
+15
View File
@@ -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
+93
View File
@@ -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
+108
View File
@@ -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
```
+810
View File
@@ -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": ""
}
+6
View File
@@ -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/
+6
View File
@@ -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/
+32 -18
View File
@@ -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
+29 -9
View File
@@ -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")"
+237
View File
@@ -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
+216
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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;
}
}