chore(integration): stage PR #505 on big-main

This commit is contained in:
XiaoSeS 2026-07-29 17:40:55 +08:00
commit 3d90583669
25 changed files with 963 additions and 12 deletions

View file

@ -32,6 +32,25 @@ POSTGRES_PASSWORD=TODO_change_to_a_strong_database_password
REDIS_BIND_ADDRESS=127.0.0.1
REDIS_PORT=6379
# Optional external Redis Cluster. Leave commented to use bundled standalone Redis.
# All advertised node addresses must be reachable from the server container.
# SPRING_DATA_REDIS_CLUSTER_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379
# SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
# SPRING_DATA_REDIS_USERNAME=
# SPRING_DATA_REDIS_PASSWORD=
# SPRING_DATA_REDIS_SSL_ENABLED=true
# SPRING_DATA_REDIS_CONNECT_TIMEOUT=5s
# SPRING_DATA_REDIS_TIMEOUT=3s
# SPRING_DATA_REDIS_CLIENT_NAME=skillhub
# Optional external Redis Sentinel. Sentinel takes precedence if both Sentinel
# and Cluster settings are present.
# SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
# SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379
# SPRING_DATA_REDIS_SENTINEL_USERNAME=
# SPRING_DATA_REDIS_SENTINEL_PASSWORD=
# SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=true
# Host ports exposed by the app containers.
API_PORT=8080
WEB_PORT=80

View file

@ -27,6 +27,28 @@ POSTGRES_PASSWORD=change-this-postgres-password
REDIS_BIND_ADDRESS=127.0.0.1
REDIS_PORT=6379
# Optional external Redis connection. Leave these commented to use the bundled
# standalone Redis service. For Redis Cluster, every advertised node address
# must be reachable from the server container.
# SPRING_DATA_REDIS_CLUSTER_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379
# SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
# SPRING_DATA_REDIS_USERNAME=
# SPRING_DATA_REDIS_PASSWORD=
# SPRING_DATA_REDIS_SSL_ENABLED=false
# SPRING_DATA_REDIS_CONNECT_TIMEOUT=5s
# SPRING_DATA_REDIS_TIMEOUT=3s
# SPRING_DATA_REDIS_CLIENT_NAME=skillhub
# Optional external Redis Sentinel. Sentinel takes precedence if both Sentinel
# and Cluster settings are present. Use separate credentials when Sentinel ACL
# differs from the Redis data nodes.
# SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
# SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379
# SPRING_DATA_REDIS_SENTINEL_USERNAME=
# SPRING_DATA_REDIS_SENTINEL_PASSWORD=
# SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=true
API_PORT=8080
WEB_PORT=80
SESSION_COOKIE_SECURE=false

View file

@ -94,6 +94,12 @@ jobs:
args: >-
--set redis.architecture=replication
--set redis.sentinel.enabled=true
- name: external-redis-cluster
description: 外部 Redis Cluster
args: >-
--set redis.enabled=false
--set externalRedis.cluster.enabled=true
--set-json 'externalRedis.cluster.nodes=["redis-0.example.com:6379","redis-1.example.com:6379","redis-2.example.com:6379"]'
- name: ingress-tls-certmanager
description: Ingress + TLS + cert-manager
args: >-

View file

@ -1,4 +1,4 @@
.PHONY: build build-backend build-backend-app build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
.PHONY: build build-backend build-backend-app build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
DEV_DIR := .dev
DEV_SERVER_PID := $(DEV_DIR)/server.pid
@ -394,3 +394,6 @@ docs-build: ## 构建文档站点
docs-preview: ## 预览构建后的文档站点
cd docs/skillhub && npm run preview
test-redis-cluster: ## 使用真实 Redis Cluster 验证 Spring Data、Session 和 Redisson Stream
./scripts/redis-cluster-integration-test.sh

View file

@ -294,6 +294,10 @@ Sentinel 地址。Chart **不负责数据库架构切换时的数据迁移**
standalone → replication、Redis standalone/replication → Sentinel 等切换,必须由
运维人员在 Chart 之外完成备份、恢复、PVC 复用或其他迁移方案。
外部 Redis Cluster 由云服务或运维系统提供Chart 只负责注入连接配置,不创建
Cluster也不将其计入上述内置架构运行时验证范围。应用侧应另行验证 Spring
Data、Spring Session 与 Redisson Stream 链路。
### Redis Sentinel
内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和
@ -333,6 +337,48 @@ server:
value: "false"
```
### 外部 Redis Cluster
Chart 不创建内置 Redis Cluster。生产环境的 Cluster 由云 Redis 或独立运维系统
提供SkillHub 通过标准 Spring Boot 配置连接。至少配置一个 seed 节点,且
Cluster 通告的所有节点地址都必须能从 Server Pod 访问:
```yaml
redis:
enabled: false
externalRedis:
username: skillhub
tls:
enabled: true
connectTimeout: 5s
timeout: 3s
clientName: skillhub-server
cluster:
enabled: true
nodes:
- redis-0.example.com:6379
- redis-1.example.com:6379
- redis-2.example.com:6379
maxRedirects: 5
```
Redis Cluster 只支持数据库 `0``maxRedirects` 交给 Spring Data/Lettuce 处理;
Redisson 使用同一节点、ACL、TLS 与超时配置并自行处理 Cluster 路由。密码建议
通过 `existingSecret``redis-password` 提供:
```bash
helm -n skillhub upgrade -i skillhub ./charts/skillhub \
-f values-production.yaml \
--set redis.enabled=false \
--set existingSecret=skillhub-production-secret \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-0.example.com:6379","redis-1.example.com:6379","redis-2.example.com:6379"]'
```
`externalRedis.sentinel.enabled``externalRedis.cluster.enabled` 互斥;内置
`redis.enabled=true` 时也不能启用外部 Cluster。
### 存储配置
| 参数 | 描述 | 默认值 |

View file

@ -190,8 +190,13 @@ app.kubernetes.io/component: scanner
{{- printf "%s-master" (include "skillhub.redis.fullname" .) -}}
{{- end -}}
{{- else -}}
{{- if .Values.externalRedis.cluster.enabled -}}
{{- $node := first .Values.externalRedis.cluster.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else -}}
{{- .Values.externalRedis.host -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- /* Redis Port */}}
@ -203,7 +208,10 @@ app.kubernetes.io/component: scanner
{{- print "6379" -}}
{{- end -}}
{{- else -}}
{{- if .Values.externalRedis.sentinel.enabled -}}
{{- if .Values.externalRedis.cluster.enabled -}}
{{- $node := first .Values.externalRedis.cluster.nodes -}}
{{- last (splitList ":" $node) -}}
{{- else if .Values.externalRedis.sentinel.enabled -}}
{{- $node := first .Values.externalRedis.sentinel.nodes -}}
{{- last (splitList ":" $node) -}}
{{- else -}}
@ -245,7 +253,10 @@ app.kubernetes.io/component: scanner
{{- if .Values.redis.enabled -}}
{{- include "skillhub.redis.host" . -}}
{{- else -}}
{{- if .Values.externalRedis.sentinel.enabled -}}
{{- if .Values.externalRedis.cluster.enabled -}}
{{- $node := first .Values.externalRedis.cluster.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else if .Values.externalRedis.sentinel.enabled -}}
{{- $node := first .Values.externalRedis.sentinel.nodes -}}
{{- first (splitList ":" $node) -}}
{{- else -}}

View file

@ -109,6 +109,11 @@ spec:
value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }}
- name: SPRING_DATA_REDIS_SENTINEL_NODES
value: {{ join "," .Values.externalRedis.sentinel.nodes | quote }}
{{- else if and (not .Values.redis.enabled) .Values.externalRedis.cluster.enabled }}
- name: SPRING_DATA_REDIS_CLUSTER_NODES
value: {{ join "," .Values.externalRedis.cluster.nodes | quote }}
- name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS
value: {{ .Values.externalRedis.cluster.maxRedirects | quote }}
{{- else }}
- name: SPRING_DATA_REDIS_HOST
valueFrom:
@ -122,6 +127,31 @@ spec:
key: redis-port
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.username }}
- name: SPRING_DATA_REDIS_USERNAME
value: {{ .Values.externalRedis.username | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.tls.enabled }}
- name: SPRING_DATA_REDIS_SSL_ENABLED
value: "true"
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.connectTimeout }}
- name: SPRING_DATA_REDIS_CONNECT_TIMEOUT
value: {{ .Values.externalRedis.connectTimeout | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.timeout }}
- name: SPRING_DATA_REDIS_TIMEOUT
value: {{ .Values.externalRedis.timeout | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.clientName }}
- name: SPRING_DATA_REDIS_CLIENT_NAME
value: {{ .Values.externalRedis.clientName | quote }}
{{- end }}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled .Values.externalRedis.sentinel.username }}
- name: SPRING_DATA_REDIS_SENTINEL_USERNAME
value: {{ .Values.externalRedis.sentinel.username | quote }}
{{- end }}
{{- if or (and .Values.redis.enabled .Values.redis.sentinel.enabled) (and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled) }}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
@ -145,7 +175,7 @@ spec:
key: redis-sentinel-password
{{- end }}
optional: true
{{- else if or .Values.redis.enabled .Values.externalRedis.password }}
{{- else if or .Values.redis.enabled .Values.externalRedis.password .Values.existingSecret }}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
secretKeyRef:

View file

@ -56,12 +56,33 @@
{{- if and (not .Values.postgresql.enabled) (not .Values.externalDatabase.host) -}}
{{- fail "postgresql.enabled=false requires externalDatabase.host for dependency checks" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.sentinel.enabled) (not .Values.externalRedis.host) -}}
{{- if and .Values.externalRedis.sentinel.enabled .Values.externalRedis.cluster.enabled -}}
{{- fail "externalRedis.sentinel.enabled and externalRedis.cluster.enabled are mutually exclusive" -}}
{{- end -}}
{{- if and .Values.redis.enabled .Values.externalRedis.cluster.enabled -}}
{{- fail "externalRedis.cluster.enabled=true requires redis.enabled=false" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.sentinel.enabled) (not .Values.externalRedis.cluster.enabled) (not .Values.externalRedis.host) -}}
{{- fail "redis.enabled=false requires externalRedis.host" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled (not .Values.externalRedis.sentinel.nodes) -}}
{{- fail "external Redis Sentinel requires at least one externalRedis.sentinel.nodes entry" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.cluster.enabled (not .Values.externalRedis.cluster.nodes) -}}
{{- fail "external Redis Cluster requires at least one externalRedis.cluster.nodes entry" -}}
{{- end -}}
{{- if and (not .Values.redis.enabled) .Values.externalRedis.cluster.enabled -}}
{{- range $node := .Values.externalRedis.cluster.nodes -}}
{{- if not (regexMatch "^[A-Za-z0-9._-]+:[0-9]{1,5}$" $node) -}}
{{- fail (printf "externalRedis.cluster.nodes entry must use host:port: %s" $node) -}}
{{- end -}}
{{- $parts := splitList ":" $node -}}
{{- $port := last $parts | int -}}
{{- if or (lt $port 1) (gt $port 65535) -}}
{{- fail (printf "externalRedis.cluster.nodes port must be between 1 and 65535: %s" $node) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- if and .Values.s3.endpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.endpoint)) -}}
{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}}
{{- end -}}

View file

@ -123,18 +123,65 @@ render external-sentinel "$CHART_DIR" \
--set postgresql.enabled=false \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set externalRedis.username=redis-user \
--set externalRedis.password=redis-password \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.sentinel.username=sentinel-user \
--set externalRedis.sentinel.password=sentinel-password \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a:26379","sentinel-b:26379"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-sentinel.yaml"
grep -Fq 'value: "sentinel-a"' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -A1 -F 'name: SPRING_DATA_REDIS_USERNAME' "$TMP_DIR/external-sentinel.yaml" \
| grep -Fq 'value: "redis-user"'
grep -A1 -F 'name: SPRING_DATA_REDIS_SENTINEL_USERNAME' "$TMP_DIR/external-sentinel.yaml" \
| grep -Fq 'value: "sentinel-user"'
if grep -Fq 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/external-sentinel.yaml"; then
fail "external Sentinel must preserve Redisson address consistency checks by default"
fi
render external-cluster "$CHART_DIR" \
--set postgresql.enabled=false \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set existingSecret=skillhub-production-secret \
--set externalRedis.username=skillhub \
--set externalRedis.tls.enabled=true \
--set externalRedis.connectTimeout=5s \
--set externalRedis.timeout=3s \
--set externalRedis.clientName=skillhub-server \
--set externalRedis.cluster.enabled=true \
--set externalRedis.cluster.maxRedirects=7 \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379","redis-b.example.com:6380"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-cluster.yaml"
grep -A1 -F 'name: REDIS_HOST' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "redis-a.example.com"'
grep -A1 -F 'name: REDIS_PORT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "6379"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLUSTER_NODES' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "redis-a.example.com:6379,redis-b.example.com:6380"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "7"'
grep -A1 -F 'name: SPRING_DATA_REDIS_USERNAME' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "skillhub"'
grep -A1 -F 'name: SPRING_DATA_REDIS_SSL_ENABLED' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "true"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CONNECT_TIMEOUT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "5s"'
grep -A1 -F 'name: SPRING_DATA_REDIS_TIMEOUT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "3s"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLIENT_NAME' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "skillhub-server"'
grep -A4 -F 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'name: skillhub-production-secret'
if grep -Fq 'name: SPRING_DATA_REDIS_HOST' "$TMP_DIR/external-cluster.yaml"; then
fail "external Redis Cluster must not render standalone host configuration"
fi
if grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_NODES' "$TMP_DIR/external-cluster.yaml"; then
fail "external Redis Cluster must not render Sentinel configuration"
fi
render special "$CHART_DIR" \
--set-string 'bootstrapAdmin.displayName=Ops: Admin' \
--show-only templates/configmap.yaml >"$TMP_DIR/special.yaml"
@ -229,6 +276,26 @@ assert_rejected old-postgres-env --set-json 'postgresql.primary.extraEnv=[{"name
assert_rejected old-sentinel-password --set redis.auth.sentinelPassword=unused
assert_rejected old-sentinel-nodes --set redis.sentinel.nodes=unused
assert_rejected old-sentinel-service-switch --set redis.sentinel.service.enabled=false
assert_rejected internal-and-external-cluster \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379"]'
assert_rejected sentinel-and-cluster \
--set redis.enabled=false \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a.example.com:26379"]' \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379"]'
assert_rejected cluster-without-nodes \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true
assert_rejected cluster-invalid-node \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com"]'
assert_rejected cluster-invalid-port \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:65536"]'
assert_rejected invalid-fullname --set fullnameOverride=INVALID_NAME
assert_rejected old-ingress-host --set ingress.host=old.example.com
assert_rejected old-ingress-tls-object --set ingress.tls.enabled=true

View file

@ -210,15 +210,27 @@
"externalRedis": {
"type": "object",
"additionalProperties": false,
"required": ["host", "port", "password", "sentinel"],
"required": ["host", "port", "username", "password", "tls", "connectTimeout", "timeout", "clientName", "sentinel", "cluster"],
"properties": {
"host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"username": { "type": "string" },
"password": { "type": "string" },
"tls": {
"type": "object",
"additionalProperties": false,
"required": ["enabled"],
"properties": {
"enabled": { "type": "boolean" }
}
},
"connectTimeout": { "type": "string" },
"timeout": { "type": "string" },
"clientName": { "type": "string" },
"sentinel": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "masterSet", "nodes", "password"],
"required": ["enabled", "masterSet", "nodes", "username", "password"],
"properties": {
"enabled": { "type": "boolean" },
"masterSet": { "type": "string", "minLength": 1 },
@ -226,8 +238,22 @@
"type": "array",
"items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" }
},
"username": { "type": "string" },
"password": { "type": "string" }
}
},
"cluster": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "nodes", "maxRedirects"],
"properties": {
"enabled": { "type": "boolean" },
"nodes": {
"type": "array",
"items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" }
},
"maxRedirects": { "type": "integer", "minimum": 0 }
}
}
}
},

View file

@ -257,12 +257,23 @@ redis:
externalRedis:
host: ""
port: 6379
username: ""
password: ""
tls:
enabled: false
connectTimeout: ""
timeout: ""
clientName: ""
sentinel:
enabled: false
masterSet: mymaster
nodes: []
username: ""
password: ""
cluster:
enabled: false
nodes: []
maxRedirects: 5
# ============================================================================
# Server 配置

View file

@ -53,8 +53,22 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
REDIS_HOST: redis
REDIS_PORT: 6379
SPRING_DATA_REDIS_HOST: ${SPRING_DATA_REDIS_HOST:-redis}
SPRING_DATA_REDIS_PORT: ${SPRING_DATA_REDIS_PORT:-6379}
SPRING_DATA_REDIS_USERNAME:
SPRING_DATA_REDIS_PASSWORD:
SPRING_DATA_REDIS_DATABASE:
SPRING_DATA_REDIS_SENTINEL_MASTER:
SPRING_DATA_REDIS_SENTINEL_NODES:
SPRING_DATA_REDIS_SENTINEL_USERNAME:
SPRING_DATA_REDIS_SENTINEL_PASSWORD:
SPRING_DATA_REDIS_CLUSTER_NODES:
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS:
SPRING_DATA_REDIS_SSL_ENABLED:
SPRING_DATA_REDIS_CONNECT_TIMEOUT:
SPRING_DATA_REDIS_TIMEOUT:
SPRING_DATA_REDIS_CLIENT_NAME:
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}

View file

@ -59,6 +59,8 @@ cp secret.yaml.example secret.yaml
| spring-datasource-url | PostgreSQL 连接 URL | 是 |
| spring-datasource-username | 数据库用户名 | 是 |
| spring-datasource-password | 数据库密码 | 是 |
| redis-password | Redis 数据节点密码 | 否 |
| redis-sentinel-password | Redis Sentinel 独立密码 | 否 |
| bootstrap-admin-password | 管理员密码 | 是 |
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
@ -86,9 +88,38 @@ redis-host: your-redis-host
redis-port: "6379"
```
2. 修改 `base/secret.yaml` 中的数据库连接:
连接外部 Redis Cluster 时改为配置节点列表。保留 `redis-host`/`redis-port`
不会影响 Cluster 选择:
```yaml
redis-cluster-nodes: "redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379"
redis-cluster-max-redirects: "5"
redis-username: "skillhub"
redis-ssl-enabled: "true"
redis-connect-timeout: "5s"
redis-timeout: "3s"
redis-client-name: "skillhub"
```
所有 Cluster 节点通告的地址都必须能从 Server Pod 访问。Redis Cluster 只支持
数据库 `0`
连接外部 Redis Sentinel 时配置 master、节点和独立 ACLSentinel 配置优先于
Cluster 和单机 host/port
```yaml
redis-username: "skillhub"
redis-sentinel-master: "mymaster"
redis-sentinel-nodes: "sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379"
redis-sentinel-username: "sentinel-user"
redis-sentinel-check-list: "true"
```
2. 修改 `base/secret.yaml` 中的数据库和 Redis 凭据:
```yaml
spring-datasource-url: jdbc:postgresql://your-postgres-host:5432/skillhub
redis-password: your-redis-password
redis-sentinel-password: your-sentinel-password
```
3. 部署:
@ -171,6 +202,17 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
|---|---|---|
| redis-host | redis | Redis 主机地址 |
| redis-port | 6379 | Redis 端口 |
| redis-cluster-nodes | 未设置 | 外部 Redis Cluster 节点,逗号分隔的 `host:port` |
| redis-cluster-max-redirects | 未设置 | Cluster MOVED/ASK 最大重定向次数 |
| redis-sentinel-master | 未设置 | Sentinel master set 名称 |
| redis-sentinel-nodes | 未设置 | Sentinel 节点,逗号分隔的 `host:port` |
| redis-sentinel-username | 未设置 | Sentinel ACL 用户名 |
| redis-sentinel-check-list | true | 是否校验 Sentinel 返回的节点列表 |
| redis-username | 未设置 | Redis ACL 用户名 |
| redis-ssl-enabled | 未设置 | 是否使用 TLS |
| redis-connect-timeout | 未设置 | Redis 建连超时 |
| redis-timeout | 未设置 | Redis 命令超时 |
| redis-client-name | 未设置 | Redis 客户端名称 |
| storage-base-path | /var/lib/skillhub/storage | 技能存储路径 |
| skillhub-storage-provider | local | 存储类型local/s3 |
| skill-scanner-enabled | true | 是否启用扫描器 |
@ -190,6 +232,8 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
| spring-datasource-url | PostgreSQL 连接 URL | 是 |
| spring-datasource-username | 数据库用户名 | 是 |
| spring-datasource-password | 数据库密码 | 是 |
| redis-password | Redis 数据节点密码 | 否 |
| redis-sentinel-password | Redis Sentinel 独立密码 | 否 |
| bootstrap-admin-password | 管理员密码 | 是 |
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |

View file

@ -53,6 +53,84 @@ spec:
configMapKeyRef:
name: skillhub-config
key: redis-port
- name: SPRING_DATA_REDIS_CLUSTER_NODES
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-cluster-nodes
optional: true
- name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-cluster-max-redirects
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_MASTER
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-master
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_NODES
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-nodes
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_USERNAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-username
optional: true
- name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-sentinel-check-list
optional: true
- name: SPRING_DATA_REDIS_USERNAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-username
optional: true
- name: SPRING_DATA_REDIS_SSL_ENABLED
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-ssl-enabled
optional: true
- name: SPRING_DATA_REDIS_CONNECT_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-connect-timeout
optional: true
- name: SPRING_DATA_REDIS_TIMEOUT
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-timeout
optional: true
- name: SPRING_DATA_REDIS_CLIENT_NAME
valueFrom:
configMapKeyRef:
name: skillhub-config
key: redis-client-name
optional: true
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: skillhub-secret
key: redis-password
optional: true
- name: SPRING_DATA_REDIS_SENTINEL_PASSWORD
valueFrom:
secretKeyRef:
name: skillhub-secret
key: redis-sentinel-password
optional: true
# Storage
- name: STORAGE_BASE_PATH

View file

@ -8,6 +8,21 @@ data:
# 使用内置 Redisoverlays/with-infra保持 redis
redis-host: redis
redis-port: "6379"
# 连接外部 Redis Cluster 时取消下面几行注释。Cluster 节点存在时,
# Spring Boot 会自动忽略上面的单机 host/port。
# redis-cluster-nodes: "redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379"
# redis-cluster-max-redirects: "5"
# redis-username: "skillhub"
# redis-ssl-enabled: "true"
# redis-connect-timeout: "5s"
# redis-timeout: "3s"
# redis-client-name: "skillhub"
# 连接外部 Redis Sentinel 时取消下面几行注释。Sentinel 配置优先于
# Cluster 和单机 host/port。
# redis-sentinel-master: "mymaster"
# redis-sentinel-nodes: "sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379"
# redis-sentinel-username: "sentinel-user"
# redis-sentinel-check-list: "true"
# 技能存储路径
storage-base-path: /var/lib/skillhub/storage

View file

@ -15,6 +15,11 @@ stringData:
spring-datasource-username: skillhub
spring-datasource-password: change-me
# Redis 数据节点密码单机、Sentinel 与 Cluster 共用;无密码时留空)
redis-password: ""
# Redis Sentinel 独立密码;与数据节点相同时也可填写同一个值
redis-sentinel-password: ""
# Bootstrap 管理员密码(敏感)
bootstrap-admin-password: ChangeMe!2026

View file

@ -107,7 +107,56 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- Web UI: `SKILLHUB_PUBLIC_BASE_URL`
- Backend API: `http://localhost:8080`
### 5.2 关键文件
### 5.2 连接外部 Redis Cluster
发布 Compose 默认仍使用内置单机 Redis。连接外部 Redis Cluster 时,在
`.env.release` 中设置标准 Spring Boot 配置,不需要额外的模式开关:
```dotenv
SPRING_DATA_REDIS_CLUSTER_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
SPRING_DATA_REDIS_USERNAME=skillhub
SPRING_DATA_REDIS_PASSWORD=replace-with-secret
SPRING_DATA_REDIS_SSL_ENABLED=true
SPRING_DATA_REDIS_CONNECT_TIMEOUT=5s
SPRING_DATA_REDIS_TIMEOUT=3s
```
Cluster 节点返回给客户端的所有地址必须能从 `server` 容器访问。Redis Cluster
只支持数据库 `0`;不要为 Cluster 设置非零的
`SPRING_DATA_REDIS_DATABASE`。配置 Cluster 节点后Spring Boot 自动忽略单机
`host`/`port`Compose 中的内置 Redis 容器仍会启动,但不会被 Server 使用。
对真实 Cluster 运行功能检查:
```bash
REDIS_CLUSTER_TEST_NODES=redis-0.example.com:6379,redis-1.example.com:6379,redis-2.example.com:6379 \
REDIS_CLUSTER_TEST_USERNAME=skillhub \
REDIS_CLUSTER_TEST_PASSWORD=replace-with-secret \
make test-redis-cluster
```
该检查覆盖 Spring Data 读写、Spring Session 保存/读取/删除和 Redisson Stream。
### 5.3 连接外部 Redis Sentinel
Sentinel 使用标准 Spring Boot 配置。数据节点和 Sentinel 可以使用不同 ACL
```dotenv
SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-0.example.com:26379,sentinel-1.example.com:26379,sentinel-2.example.com:26379
SPRING_DATA_REDIS_USERNAME=skillhub
SPRING_DATA_REDIS_PASSWORD=replace-with-data-node-secret
SPRING_DATA_REDIS_SENTINEL_USERNAME=sentinel-user
SPRING_DATA_REDIS_SENTINEL_PASSWORD=replace-with-sentinel-secret
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=true
```
Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Sentinel
返回地址与客户端入口不一致的环境中,可以将
`SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST` 设为 `false`
### 5.4 关键文件
- `compose.release.yml`
- 使用发布镜像,不在用户机器上执行本地构建
@ -121,7 +170,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 在启动前校验 `.env.release`
- 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值
### 5.3 镜像标签约定
### 5.5 镜像标签约定
- `edge`
- `main` 分支最新构建

View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
NODES=${REDIS_CLUSTER_TEST_NODES:-${SPRING_DATA_REDIS_CLUSTER_NODES:-}}
if [[ -z "$NODES" ]]; then
echo "ERROR: REDIS_CLUSTER_TEST_NODES or SPRING_DATA_REDIS_CLUSTER_NODES is required" >&2
exit 2
fi
export REDIS_CLUSTER_TEST_NODES="$NODES"
export REDIS_CLUSTER_TEST_USERNAME="${REDIS_CLUSTER_TEST_USERNAME:-${SPRING_DATA_REDIS_USERNAME:-}}"
export REDIS_CLUSTER_TEST_PASSWORD="${REDIS_CLUSTER_TEST_PASSWORD:-${SPRING_DATA_REDIS_PASSWORD:-}}"
export REDIS_CLUSTER_TEST_SSL_ENABLED="${REDIS_CLUSTER_TEST_SSL_ENABLED:-${SPRING_DATA_REDIS_SSL_ENABLED:-false}}"
cd "$REPO_ROOT/server"
JDK_JAVA_OPTIONS="${JDK_JAVA_OPTIONS:-} -XX:+EnableDynamicAgentLoading" \
./mvnw -pl skillhub-app -am test \
-Dtest=RedisClusterIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false

View file

@ -86,6 +86,70 @@ write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minim
printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env"
expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false"
valid_redis_cluster_env="$tmp/valid-redis-cluster.env"
write_env "$valid_redis_cluster_env" "release-download-secret-32-bytes-minimum"
cat >>"$valid_redis_cluster_env" <<'EOF'
SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com:6379,redis-b.example.com:6380
SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=5
SPRING_DATA_REDIS_SSL_ENABLED=true
EOF
"$SCRIPT" "$valid_redis_cluster_env" >/dev/null
invalid_redis_cluster_node_env="$tmp/invalid-redis-cluster-node.env"
write_env "$invalid_redis_cluster_node_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com" >>"$invalid_redis_cluster_node_env"
expect_fail "$invalid_redis_cluster_node_env" "SPRING_DATA_REDIS_CLUSTER_NODES entries must use host:port"
invalid_redis_cluster_port_env="$tmp/invalid-redis-cluster-port.env"
write_env "$invalid_redis_cluster_port_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com:65536" >>"$invalid_redis_cluster_port_env"
expect_fail "$invalid_redis_cluster_port_env" "SPRING_DATA_REDIS_CLUSTER_NODES port must be between 1 and 65535"
invalid_redis_redirects_env="$tmp/invalid-redis-redirects.env"
write_env "$invalid_redis_redirects_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS=-1" >>"$invalid_redis_redirects_env"
expect_fail "$invalid_redis_redirects_env" "SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS must be a non-negative integer"
invalid_redis_database_env="$tmp/invalid-redis-database.env"
write_env "$invalid_redis_database_env" "release-download-secret-32-bytes-minimum"
cat >>"$invalid_redis_database_env" <<'EOF'
SPRING_DATA_REDIS_CLUSTER_NODES=redis-a.example.com:6379
SPRING_DATA_REDIS_DATABASE=1
EOF
expect_fail "$invalid_redis_database_env" "SPRING_DATA_REDIS_DATABASE must be 0 when SPRING_DATA_REDIS_CLUSTER_NODES is set"
valid_redis_sentinel_env="$tmp/valid-redis-sentinel.env"
write_env "$valid_redis_sentinel_env" "release-download-secret-32-bytes-minimum"
cat >>"$valid_redis_sentinel_env" <<'EOF'
SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-a.example.com:26379,sentinel-b.example.com:26379
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=false
EOF
"$SCRIPT" "$valid_redis_sentinel_env" >/dev/null
missing_redis_sentinel_nodes_env="$tmp/missing-redis-sentinel-nodes.env"
write_env "$missing_redis_sentinel_nodes_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster" >>"$missing_redis_sentinel_nodes_env"
expect_fail "$missing_redis_sentinel_nodes_env" "SPRING_DATA_REDIS_SENTINEL_NODES is required when SPRING_DATA_REDIS_SENTINEL_MASTER is set"
missing_redis_sentinel_master_env="$tmp/missing-redis-sentinel-master.env"
write_env "$missing_redis_sentinel_master_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-a.example.com:26379" >>"$missing_redis_sentinel_master_env"
expect_fail "$missing_redis_sentinel_master_env" "SPRING_DATA_REDIS_SENTINEL_MASTER is required when SPRING_DATA_REDIS_SENTINEL_NODES is set"
invalid_redis_sentinel_port_env="$tmp/invalid-redis-sentinel-port.env"
write_env "$invalid_redis_sentinel_port_env" "release-download-secret-32-bytes-minimum"
cat >>"$invalid_redis_sentinel_port_env" <<'EOF'
SPRING_DATA_REDIS_SENTINEL_MASTER=mymaster
SPRING_DATA_REDIS_SENTINEL_NODES=sentinel-a.example.com:70000
EOF
expect_fail "$invalid_redis_sentinel_port_env" "SPRING_DATA_REDIS_SENTINEL_NODES port must be between 1 and 65535"
invalid_redis_sentinel_check_env="$tmp/invalid-redis-sentinel-check.env"
write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env"
expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false"
draft_env="$tmp/draft.env"
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in

View file

@ -126,6 +126,74 @@ validate_min_length() {
fi
}
validate_non_negative_integer() {
var_name="$1"
eval "var_value=\${$var_name:-}"
case "$var_value" in
"") ;;
*[!0-9]*) error "$var_name must be a non-negative integer" ;;
esac
}
validate_redis_nodes() {
var_name="$1"
eval "nodes=\${$var_name:-}"
if [ -z "$nodes" ]; then
return 0
fi
old_ifs="$IFS"
IFS=","
for node in $nodes; do
host=${node%:*}
port=${node##*:}
if [ -z "$host" ] || [ "$host" = "$node" ]; then
error "$var_name entries must use host:port"
continue
fi
case "$host" in
*[!A-Za-z0-9._-]*)
error "$var_name contains an invalid host: $host"
;;
esac
case "$port" in
*[!0-9]*|"")
error "$var_name contains an invalid port: $node"
;;
*)
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
error "$var_name port must be between 1 and 65535: $node"
fi
;;
esac
done
IFS="$old_ifs"
}
validate_redis_sentinel_configuration() {
master="${SPRING_DATA_REDIS_SENTINEL_MASTER:-}"
nodes="${SPRING_DATA_REDIS_SENTINEL_NODES:-}"
if [ -n "$master" ] && [ -z "$nodes" ]; then
error "SPRING_DATA_REDIS_SENTINEL_NODES is required when SPRING_DATA_REDIS_SENTINEL_MASTER is set"
fi
if [ -n "$nodes" ] && [ -z "$master" ]; then
error "SPRING_DATA_REDIS_SENTINEL_MASTER is required when SPRING_DATA_REDIS_SENTINEL_NODES is set"
fi
validate_redis_nodes SPRING_DATA_REDIS_SENTINEL_NODES
}
validate_redis_cluster_database() {
if [ -z "${SPRING_DATA_REDIS_CLUSTER_NODES:-}" ]; then
return 0
fi
case "${SPRING_DATA_REDIS_DATABASE:-0}" in
0) ;;
*) error "SPRING_DATA_REDIS_DATABASE must be 0 when SPRING_DATA_REDIS_CLUSTER_NODES is set" ;;
esac
}
require_non_empty SKILLHUB_PUBLIC_BASE_URL
validate_url SKILLHUB_PUBLIC_BASE_URL
validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL
@ -154,11 +222,17 @@ validate_boolean BOOTSTRAP_ADMIN_ENABLED
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET
validate_boolean SPRING_DATA_REDIS_SSL_ENABLED
validate_boolean SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
validate_port POSTGRES_PORT
validate_port REDIS_PORT
validate_port API_PORT
validate_port WEB_PORT
validate_non_negative_integer SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS
validate_redis_nodes SPRING_DATA_REDIS_CLUSTER_NODES
validate_redis_cluster_database
validate_redis_sentinel_configuration
require_non_empty POSTGRES_DB
require_non_empty POSTGRES_USER

View file

@ -2,10 +2,12 @@ package com.iflytek.skillhub.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
@ -19,6 +21,7 @@ import java.util.List;
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(RedissonClient.class)
public RedissonClient redissonClient(
RedisProperties redisProperties,
@Value("${skillhub.redis.sentinel.check-sentinels-list:true}") boolean checkSentinelsList) {
@ -35,6 +38,10 @@ public class RedissonConfig {
configureSentinelServers(config, redisProperties, checkSentinelsList);
return config;
}
if (hasClusterConfiguration(redisProperties)) {
configureClusterServers(config, redisProperties);
return config;
}
SingleServerConfig singleServerConfig = config.useSingleServer()
.setAddress(resolveAddress(redisProperties))
@ -64,6 +71,20 @@ public class RedissonConfig {
if (StringUtils.hasText(redisProperties.getSentinel().getPassword())) {
sentinelServersConfig.setSentinelPassword(redisProperties.getSentinel().getPassword());
}
if (StringUtils.hasText(redisProperties.getSentinel().getUsername())) {
sentinelServersConfig.setSentinelUsername(redisProperties.getSentinel().getUsername());
}
}
private static void configureClusterServers(Config config, RedisProperties redisProperties) {
ClusterServersConfig clusterServersConfig = config.useClusterServers();
redisProperties.getCluster().getNodes().stream()
.map(String::trim)
.filter(StringUtils::hasText)
.map(node -> withRedisScheme(node, redisProperties))
.forEach(clusterServersConfig::addNodeAddress);
applySharedSettings(clusterServersConfig, redisProperties);
}
private static boolean hasSentinelConfiguration(RedisProperties redisProperties) {
@ -73,6 +94,12 @@ public class RedissonConfig {
&& !redisProperties.getSentinel().getNodes().isEmpty();
}
private static boolean hasClusterConfiguration(RedisProperties redisProperties) {
return redisProperties.getCluster() != null
&& redisProperties.getCluster().getNodes() != null
&& redisProperties.getCluster().getNodes().stream().anyMatch(StringUtils::hasText);
}
private static void applySharedSettings(org.redisson.config.BaseConfig<?> serverConfig,
RedisProperties redisProperties) {
if (StringUtils.hasText(redisProperties.getUsername())) {

View file

@ -42,6 +42,7 @@ spring:
host: ${SPRING_DATA_REDIS_HOST:${REDIS_HOST:localhost}}
port: ${SPRING_DATA_REDIS_PORT:${REDIS_PORT:6379}}
password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}}
database: ${SPRING_DATA_REDIS_DATABASE:0}
session:
store-type: redis
redis:

View file

@ -0,0 +1,136 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.redisson.Redisson;
import org.redisson.api.RStream;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.redisson.api.stream.StreamAddArgs.entry;
@Tag("redis-cluster")
@EnabledIfEnvironmentVariable(named = "REDIS_CLUSTER_TEST_NODES", matches = ".+")
class RedisClusterIntegrationTest {
@Test
void clusterSupportsSpringDataSessionsAndRedissonStreams() {
List<String> nodes = clusterNodes();
RedisClusterConfiguration clusterConfiguration = new RedisClusterConfiguration(nodes);
String username = System.getenv("REDIS_CLUSTER_TEST_USERNAME");
String password = System.getenv("REDIS_CLUSTER_TEST_PASSWORD");
if (username != null && !username.isBlank()) {
clusterConfiguration.setUsername(username);
}
if (password != null && !password.isBlank()) {
clusterConfiguration.setPassword(password);
}
boolean sslEnabled = Boolean.parseBoolean(System.getenv("REDIS_CLUSTER_TEST_SSL_ENABLED"));
LettuceClientConfiguration.LettuceClientConfigurationBuilder clientConfiguration =
LettuceClientConfiguration.builder();
if (sslEnabled) {
clientConfiguration.useSsl();
}
LettuceConnectionFactory connectionFactory =
new LettuceConnectionFactory(clusterConfiguration, clientConfiguration.build());
connectionFactory.afterPropertiesSet();
RedisProperties properties = new RedisProperties();
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(nodes);
properties.setCluster(cluster);
properties.setUsername(username);
properties.setPassword(password);
properties.getSsl().setEnabled(sslEnabled);
Config redissonConfig = RedissonConfig.createConfig(properties);
String keyPrefix = "skillhub:redis-cluster-smoke:" + UUID.randomUUID();
RedissonClient redissonClient = Redisson.create(redissonConfig);
try {
verifySpringData(connectionFactory, keyPrefix);
verifySpringSession(connectionFactory, keyPrefix);
verifyRedissonStream(redissonClient, keyPrefix);
} finally {
redissonClient.shutdown();
connectionFactory.destroy();
}
}
private void verifySpringData(LettuceConnectionFactory connectionFactory, String keyPrefix) {
StringRedisTemplate template = new StringRedisTemplate(connectionFactory);
String key = keyPrefix + ":value";
template.opsForValue().set(key, "ok");
assertThat(template.opsForValue().get(key)).isEqualTo("ok");
template.delete(key);
}
private void verifySpringSession(LettuceConnectionFactory connectionFactory, String keyPrefix) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
RedisIndexedSessionRepository repository = new RedisIndexedSessionRepository(template);
repository.setRedisKeyNamespace(keyPrefix + ":session");
repository.afterPropertiesSet();
try {
SessionRepository<Session> sessionRepository = sessionRepository(repository);
Session session = sessionRepository.createSession();
session.setAttribute("userId", "cluster-user");
sessionRepository.save(session);
Session loaded = sessionRepository.findById(session.getId());
assertThat(loaded).isNotNull();
assertThat(loaded.<String>getAttribute("userId")).isEqualTo("cluster-user");
sessionRepository.deleteById(session.getId());
assertThat(sessionRepository.findById(session.getId())).isNull();
} finally {
repository.destroy();
}
}
private void verifyRedissonStream(RedissonClient redissonClient, String keyPrefix) {
RStream<String, String> stream = redissonClient.getStream(keyPrefix + ":stream", StringCodec.INSTANCE);
try {
stream.add(entry("status", "ok"));
assertThat(stream.size()).isEqualTo(1);
} finally {
stream.delete();
}
}
private List<String> clusterNodes() {
return Arrays.stream(System.getenv("REDIS_CLUSTER_TEST_NODES").split(","))
.map(String::trim)
.filter(node -> !node.isEmpty())
.toList();
}
@SuppressWarnings({"rawtypes", "unchecked"})
private SessionRepository<Session> sessionRepository(RedisIndexedSessionRepository repository) {
return (SessionRepository) repository;
}
}

View file

@ -0,0 +1,83 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisSentinelConfiguration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import static org.assertj.core.api.Assertions.assertThat;
class RedisConnectionConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RedisAutoConfiguration.class));
@Test
void autoConfiguration_keepsStandaloneAsTheDefault() {
contextRunner
.withPropertyValues(
"spring.data.redis.host=redis.internal",
"spring.data.redis.port=6380",
"spring.data.redis.database=4")
.run(context -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
RedisStandaloneConfiguration standalone = factory.getStandaloneConfiguration();
assertThat(factory.getClusterConfiguration()).isNull();
assertThat(factory.getSentinelConfiguration()).isNull();
assertThat(standalone).isNotNull();
assertThat(standalone.getHostName()).isEqualTo("redis.internal");
assertThat(standalone.getPort()).isEqualTo(6380);
assertThat(standalone.getDatabase()).isEqualTo(4);
});
}
@Test
void autoConfiguration_selectsClusterFromStandardSpringProperties() {
contextRunner
.withPropertyValues(
"spring.data.redis.cluster.nodes=redis-1:6379,redis-2:6380",
"spring.data.redis.cluster.max-redirects=5",
"spring.data.redis.username=skillhub",
"spring.data.redis.password=secret")
.run(context -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
RedisClusterConfiguration cluster = factory.getClusterConfiguration();
assertThat(factory.isClusterAware()).isTrue();
assertThat(factory.getSentinelConfiguration()).isNull();
assertThat(cluster).isNotNull();
assertThat(cluster.getClusterNodes())
.extracting(node -> node.getHost() + ":" + node.getPort())
.containsExactly("redis-1:6379", "redis-2:6380");
assertThat(cluster.getMaxRedirects()).isEqualTo(5);
assertThat(cluster.getUsername()).isEqualTo("skillhub");
assertThat(cluster.getPassword().map(String::new).orElse(null)).isEqualTo("secret");
});
}
@Test
void autoConfiguration_prefersSentinelWhenSentinelAndClusterAreBothConfigured() {
contextRunner
.withPropertyValues(
"spring.data.redis.sentinel.master=mymaster",
"spring.data.redis.sentinel.nodes=sentinel-1:26379,sentinel-2:26379",
"spring.data.redis.cluster.nodes=redis-1:6379,redis-2:6380")
.run(context -> {
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
RedisSentinelConfiguration sentinel = factory.getSentinelConfiguration();
assertThat(factory.isRedisSentinelAware()).isTrue();
assertThat(factory.getClusterConfiguration()).isNull();
assertThat(sentinel).isNotNull();
assertThat(sentinel.getMaster().getName()).isEqualTo("mymaster");
assertThat(sentinel.getSentinels())
.extracting(node -> node.getHost() + ":" + node.getPort())
.containsExactlyInAnyOrder("sentinel-1:26379", "sentinel-2:26379");
});
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.config;
import org.junit.jupiter.api.Test;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
@ -122,6 +123,77 @@ class RedissonConfigTest {
assertThat(sentinelConfig.getPassword()).isEqualTo("master-secret");
}
@Test
void createConfig_appliesSentinelUsernameWhenSet() throws Exception {
RedisProperties properties = new RedisProperties();
RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel();
sentinel.setMaster("mymaster");
sentinel.setNodes(List.of("redis-sentinel-1:26379"));
sentinel.setUsername("sentinel-user");
properties.setSentinel(sentinel);
Config config = RedissonConfig.createConfig(properties);
SentinelServersConfig sentinelConfig = sentinelConfig(config);
assertThat(sentinelConfig.getSentinelUsername()).isEqualTo("sentinel-user");
}
@Test
void createConfig_usesClusterServersWhenClusterNodesArePresent() throws Exception {
RedisProperties properties = new RedisProperties();
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(List.of("redis-cluster-1:6379", " redis-cluster-2:6380 "));
properties.setCluster(cluster);
properties.setUsername("skillhub");
properties.setPassword("secret");
properties.setClientName("skillhub-stream");
properties.setTimeout(Duration.ofSeconds(3));
properties.setConnectTimeout(Duration.ofSeconds(5));
Config config = RedissonConfig.createConfig(properties);
ClusterServersConfig clusterConfig = clusterConfig(config);
assertThat(config.isClusterConfig()).isTrue();
assertThat(clusterConfig.getNodeAddresses())
.containsExactly("redis://redis-cluster-1:6379", "redis://redis-cluster-2:6380");
assertThat(clusterConfig.getUsername()).isEqualTo("skillhub");
assertThat(clusterConfig.getPassword()).isEqualTo("secret");
assertThat(clusterConfig.getClientName()).isEqualTo("skillhub-stream");
assertThat(clusterConfig.getTimeout()).isEqualTo(3000);
assertThat(clusterConfig.getConnectTimeout()).isEqualTo(5000);
}
@Test
void createConfig_usesSecureSchemeForClusterAddressesWhenSslEnabled() throws Exception {
RedisProperties properties = new RedisProperties();
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(List.of("redis-cluster-1:6379"));
properties.setCluster(cluster);
properties.getSsl().setEnabled(true);
Config config = RedissonConfig.createConfig(properties);
ClusterServersConfig clusterConfig = clusterConfig(config);
assertThat(clusterConfig.getNodeAddresses()).containsExactly("rediss://redis-cluster-1:6379");
}
@Test
void createConfig_prefersSentinelWhenSentinelAndClusterAreBothConfigured() {
RedisProperties properties = new RedisProperties();
RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel();
sentinel.setMaster("mymaster");
sentinel.setNodes(List.of("redis-sentinel-1:26379"));
properties.setSentinel(sentinel);
RedisProperties.Cluster cluster = new RedisProperties.Cluster();
cluster.setNodes(List.of("redis-cluster-1:6379"));
properties.setCluster(cluster);
Config config = RedissonConfig.createConfig(properties);
assertThat(config.isSentinelConfig()).isTrue();
assertThat(config.isClusterConfig()).isFalse();
}
@Test
void createConfig_keepsSentinelMembershipCheckEnabledByDefault() throws Exception {
RedisProperties properties = new RedisProperties();
@ -171,4 +243,10 @@ class RedissonConfigTest {
method.setAccessible(true);
return (SentinelServersConfig) method.invoke(config);
}
private ClusterServersConfig clusterConfig(Config config) throws Exception {
Method method = Config.class.getDeclaredMethod("getClusterServersConfig");
method.setAccessible(true);
return (ClusterServersConfig) method.invoke(config);
}
}