Merge branch 'main' into jenkins/init

This commit is contained in:
wwweww
2026-05-03 14:53:31 +00:00
60 changed files with 6062 additions and 15162 deletions
+35 -42
View File
@@ -4,63 +4,54 @@
- Docker(需要 buildx
- Python 3(构建脚本)
- Git(含 submodule:首次需 `git submodule update --init --recursive`
## 使用
```bash
cd deploy/dev
# 1. 构建所有微服务镜像
# 默认 8 并行,可通过环境变量 BAKE_BATCH_SIZE 调整
# 1. 构建所有镜像(默认 8 并行,可通过 BAKE_BATCH_SIZE 调整)
python3 build.py
# 2. 启动
docker compose up -d
# 3. 查看状态
docker compose ps
# 4. 通过网关访问
curl http://127.0.0.1:18080/healthz
# 5. 停止
docker compose down
# 3. 通过网关访问
open http://127.0.0.1:18080
```
构建脚本扫描 `app/` 下所有 `api``rpc``mq``adapter` 入口,通过 `docker buildx bake` 并行构建所有服务镜像,生成 `juwan/<service>-<type>:dev`
构建脚本扫描 `app/` 下所有 `api``rpc``mq``adapter` 入口`frontend/`,通过 `docker buildx bake` 并行构建所有镜像,生成 `juwan/<service>-<type>:dev``juwan/frontend:dev`
端到端接口测试走网关 `http://127.0.0.1:18080``18801-18814` 是各服务的直连端口,不经过认证链路
前端作为 submodule 接入 compose,通过 envoy 的同源 fallback 路由向浏览器提供,无需独立端口
Chat WebSocket 通过网关 `ws://127.0.0.1:18080/ws/chat` 访问。WebTransport 使用 `18443/udp``/wt/chat` 入口
如需只启动部分服务:
```bash
docker compose up -d postgres redis snowflake player-rpc player-api
```
Chat WebSocket 通过 `ws://127.0.0.1:18080/ws/chat` 访问。WebTransport 使用 `18443/udp``/wt/chat`
## 端口映射
| 服务 | 宿主机端口 |
| ---------------- | ---------------- |
| PostgreSQL | 15432 |
| Redis | 16379 |
| Kafka | 19092 |
| Envoy Gateway | 18080 |
| users-api | 18801 |
| player-api | 18802 |
| game-api | 18803 |
| shop-api | 18804 |
| order-api | 18805 |
| wallet-api | 18806 |
| community-api | 18807 |
| objectstory-api | 18808 |
| email-api | 18809 |
| chat-api | 18810, 18443/udp |
| review-api | 18811 |
| dispute-api | 18812 |
| notification-api | 18813 |
| search-api | 18814 |
| 服务 | 宿主机端口 | 说明 |
| ---------------- | ---------------- | ---------------------------------- |
| Envoy Gateway | 18080 | 浏览器入口,`/api/*``/ws/*`、前端静态都从这里出 |
| Redis | 16379 | 共享会话与验证码 |
| MongoDB | 27017 | chat 消息持久化 |
| Kafka | 19092 | email-mq 任务队列 |
| ratelimit | 18081, 16070 | 限流服务 |
| users-api | 18801 | 直连调试入口,不经认证链路 |
| player-api | 18802 | |
| game-api | 18803 | |
| shop-api | 18804 | |
| order-api | 18805 | |
| wallet-api | 18806 | |
| community-api | 18807 | |
| objectstory-api | 18808 | |
| email-api | 18809 | |
| chat-api | 18810, 18889, 18443/udp | |
| review-api | 18811 | |
| dispute-api | 18812 | |
| notification-api | 18813 | |
| search-api | 18814 | |
11 个 per-domain PostgreSQL`users-db``player-db``game-db``shop-db``order-db``wallet-db``community-db``review-db``dispute-db``notification-db``search-db`)和 `frontend` 容器不暴露宿主端口,仅在 compose 内网通过 DNS 互访。
## 环境变量
@@ -74,17 +65,19 @@ docker compose up -d postgres redis snowflake player-rpc player-api
| ADMIN_PASSWORD | 管理员密码 | admin123 |
| ADMIN_EMAIL | 管理员邮箱 | admin@juwan.dev |
默认 admin 固定 ID `100000`,拥有消费者、打手、店主三种身份全部开通,并预置了店铺、服务、钱包、帖子等演示数据,可直接用于完整链路联调。
## 认证
登录和注册通过 `users-api` 下发 `JToken` Cookie。`envoy-gateway` 负责 JWT 校验并注入认证头,`authz-adapter` 做会话态二次校验,后端服务只消费 `x-auth-user-id` 等头。
写接口需要先 `GET /healthz` 领取 `XSRF-TOKEN``XSRF-GUARD`,再在请求头带上 `xsrf-token`
写接口需要先 `GET /healthz` 领取 `__Host-XSRF-TOKEN``__Host-XSRF-GUARD` cookie,再在请求头带上 `xsrf-token`
注册和密码重置都需要先调验证码接口拿到 `requestId`,再把它放到 `X-Request-Id` 请求头里发后续请求。
## 数据库初始化
## 数据库初始化
首次启动时 PostgreSQL 会自动执行 `desc/sql/` 下的建表语句。如需重新初始化,删除 volume 后重启
每个 per-domain PostgreSQL 首次启动时,通过挂载 `desc/sql/<domain>/``deploy/dev/fixture/<domain>.sql` 自动完成建表与演示数据导入。如需完全重置
```bash
docker compose down -v
-4
View File
@@ -1,4 +0,0 @@
HARBOR_REGISTRY=harbor.example.com
HARBOR_PROJECT=juwan
IMAGE_NAME=st-1-example
IMAGE_TAG=latest
-48
View File
@@ -1,48 +0,0 @@
# Docker 服务器部署方案(Gitea Actions
本方案替代 Jenkins
1. `push` 代码后由 Gitea Actions 构建镜像并推送 Harbor。
2. 同一工作流通过 SSH 连接服务器,执行 `docker compose pull && docker compose up -d` 完成更新。
## 1) 服务器准备
在目标服务器安装:
- Docker Engine
- Docker Compose 插件(`docker compose version` 可用)
并确保部署用户有 docker 权限:
```bash
sudo usermod -aG docker <deploy-user>
```
## 2) Gitea 仓库 Secrets
在仓库中配置以下 Secrets
- `HARBOR_REGISTRY`:例如 `harbor.example.com`
- `HARBOR_PROJECT`:例如 `juwan`
- `HARBOR_USERNAME`
- `HARBOR_PASSWORD`
- `DEPLOY_HOST`:服务器地址
- `DEPLOY_PORT`:可选,默认 `22`
- `DEPLOY_USER`:服务器 SSH 用户
- `DEPLOY_SSH_KEY`:私钥内容(PEM
- `DEPLOY_PATH`:可选,默认 `/opt/st-1-example`
## 3) 触发规则
- 构建推送:`main/master/dev/feature/**`
- 自动部署:仅 `main/master`
如需改分支规则,编辑:
- `.gitea/workflows/build-push-harbor.yml`
## 4) 端口与服务
Compose 文件:`deploy/docker/docker-compose.yml`
默认映射:`8888:8888`,服务名:`st-example`
-9
View File
@@ -1,9 +0,0 @@
services:
st-example:
image: ${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
container_name: st-example
restart: always
ports:
- "8888:8888"
environment:
TZ: Asia/Shanghai
-184
View File
@@ -1,184 +0,0 @@
# Operator 安装与示例使用
本文档提供 Strimzi Operator 与 MongoDB Community Operator 的两种安装方式:
- Helm 安装
- kubectl 安装
> 示例资源文件位于 `deploy/example`,默认使用 `juwan` 命名空间。
> 请先确保你的 Operator 能 watch 到 `juwan`,否则请改 namespace 或调整 Operator watch 范围。
## 1) Strimzi OperatorKafka
### 1.1 使用 Helm 安装
```bash
kubectl create namespace kafka
helm repo add strimzi https://strimzi.io/charts/
helm repo update
helm install strimzi-kafka-operator strimzi/strimzi-kafka-operator -n kafka
```
### 1.2 使用 kubectl 安装
```bash
kubectl create namespace kafka
kubectl apply -f https://strimzi.io/install/latest?namespace=kafka -n kafka
```
### 1.3 安装验证
```bash
kubectl get pods -n kafka
kubectl get crd | grep kafka.strimzi.io
```
### 1.4 应用 Kafka 示例
```bash
kubectl create namespace juwan
kubectl apply -f deploy/example/kafka-strimzi-example.yaml
kubectl get kafka,kafkatopic,kafkauser -n juwan
```
## 2) MongoDB Community Operator
### 2.1 使用 Helm 安装
```bash
kubectl create namespace mongodb
helm repo add mongodb https://mongodb.github.io/helm-charts
helm repo update
helm install mongodb-kubernetes-operator mongodb/community-operator -n mongodb
```
### 2.2 使用 kubectl 安装
```bash
kubectl create namespace mongodb
kubectl apply -f https://raw.githubusercontent.com/mongodb/mongodb-kubernetes-operator/master/config/crd/bases/mongodbcommunity.mongodb.com_mongodbcommunity.yaml
kubectl apply -k https://github.com/mongodb/mongodb-kubernetes-operator/config/rbac/
kubectl apply -k https://github.com/mongodb/mongodb-kubernetes-operator/config/manager/
```
### 2.3 安装验证
```bash
kubectl get pods -n mongodb
kubectl get crd | grep mongodbcommunity.mongodb.com
```
### 2.4 应用 MongoDB 示例
```bash
kubectl create namespace juwan
kubectl apply -f deploy/example/mongodb-community-example.yaml
kubectl get mongodbcommunity -n juwan
```
## 3) MongoDB:哨兵集群与分片集群搭建
### 3.1 关于“哨兵集群”的说明
MongoDB 没有 Redis Sentinel 的独立哨兵组件。
MongoDB 的高可用由 **Replica Set(副本集)** 原生完成(自动主从切换、故障恢复)。
因此在 MongoDB 场景里,“哨兵集群”通常对应为“副本集高可用集群”。
### 3.2 MongoDB“哨兵等价”方案:副本集高可用
本仓库提供了高可用副本集 YAML`deploy/example/mongodb-ha-replicaset-example.yaml`
```bash
kubectl create namespace juwan
kubectl apply -f deploy/example/mongodb-ha-replicaset-example.yaml
kubectl get mongodbcommunity -n juwan
```
查看副本集状态(任选一个 Pod 进入):
```bash
kubectl get pods -n juwan
kubectl exec -it -n juwan <mongodb-pod-name> -- mongosh --eval "rs.status()"
```
生产建议:
- 成员数保持奇数(3/5/7
- 使用持久化卷(PVC),不要用临时盘
- 跨可用区调度(反亲和)
- 开启备份与监控
### 3.3 MongoDB 分片集群架构(Sharded Cluster
分片集群由三层组成:
- Config Server ReplicaSet(保存分片元数据,建议 3 节点)
- Shard ReplicaSet(每个分片都是副本集,建议每分片 3 节点)
- Mongos(路由层,对业务暴露统一入口)
### 3.4 分片集群搭建步骤(kubectl 方式)
> 说明:MongoDB Community Operator 主要用于副本集管理。分片集群在社区实践中通常采用“手动编排(StatefulSet/Service+ mongosh 初始化”。
本仓库提供了分片集群基础编排 YAML:`deploy/example/mongodb-sharded-cluster-example.yaml`
```bash
kubectl create namespace juwan
kubectl apply -f deploy/example/mongodb-sharded-cluster-example.yaml
kubectl get pods,svc -n juwan
```
1) 部署 Config Server 副本集(3 节点)
- 使用 StatefulSet + Headless Service 部署 `mongod --configsvr --replSet cfg-rs`
1) 部署 Shard 副本集(例如 `shard1-rs``shard2-rs`,每个 3 节点)
- 使用 StatefulSet + Headless Service 部署 `mongod --shardsvr --replSet <shard-rs-name>`
1) 部署 Mongos 路由层
- Deployment 部署 `mongos --configdb cfg-rs/<cfg-0>:27019,<cfg-1>:27019,<cfg-2>:27019`
1) 初始化各副本集
```bash
# 初始化 Config Server RS
kubectl exec -it -n juwan <cfg-pod-0> -- mongosh --port 27019 --eval 'rs.initiate({_id:"cfg-rs",configsvr:true,members:[{_id:0,host:"cfg-0.cfg-svc.juwan.svc.cluster.local:27019"},{_id:1,host:"cfg-1.cfg-svc.juwan.svc.cluster.local:27019"},{_id:2,host:"cfg-2.cfg-svc.juwan.svc.cluster.local:27019"}]})'
# 初始化 shard1 RS
kubectl exec -it -n juwan <shard1-pod-0> -- mongosh --port 27018 --eval 'rs.initiate({_id:"shard1-rs",members:[{_id:0,host:"shard1-0.shard1-svc.juwan.svc.cluster.local:27018"},{_id:1,host:"shard1-1.shard1-svc.juwan.svc.cluster.local:27018"},{_id:2,host:"shard1-2.shard1-svc.juwan.svc.cluster.local:27018"}]})'
# 初始化 shard2 RS
kubectl exec -it -n juwan <shard2-pod-0> -- mongosh --port 27018 --eval 'rs.initiate({_id:"shard2-rs",members:[{_id:0,host:"shard2-0.shard2-svc.juwan.svc.cluster.local:27018"},{_id:1,host:"shard2-1.shard2-svc.juwan.svc.cluster.local:27018"},{_id:2,host:"shard2-2.shard2-svc.juwan.svc.cluster.local:27018"}]})'
```
1) 通过 Mongos 注册分片并启用分片
```bash
kubectl exec -it -n juwan <mongos-pod-name> -- mongosh --port 27017 --eval 'sh.addShard("shard1-rs/shard1-0.shard1-svc.juwan.svc.cluster.local:27018,shard1-1.shard1-svc.juwan.svc.cluster.local:27018,shard1-2.shard1-svc.juwan.svc.cluster.local:27018")'
kubectl exec -it -n juwan <mongos-pod-name> -- mongosh --port 27017 --eval 'sh.addShard("shard2-rs/shard2-0.shard2-svc.juwan.svc.cluster.local:27018,shard2-1.shard2-svc.juwan.svc.cluster.local:27018,shard2-2.shard2-svc.juwan.svc.cluster.local:27018")'
kubectl exec -it -n juwan <mongos-pod-name> -- mongosh --port 27017 --eval 'sh.enableSharding("appdb")'
kubectl exec -it -n juwan <mongos-pod-name> -- mongosh --port 27017 --eval 'sh.shardCollection("appdb.user_events", {"userId": "hashed"})'
```
1) 验证分片状态
```bash
kubectl exec -it -n juwan <mongos-pod-name> -- mongosh --port 27017 --eval 'sh.status()'
```
## 4) 卸载(可选)
### StrimziHelm 安装场景)
```bash
helm uninstall strimzi-kafka-operator -n kafka
```
### MongoDB OperatorHelm 安装场景)
```bash
helm uninstall mongodb-kubernetes-operator -n mongodb
```
-80
View File
@@ -1,80 +0,0 @@
# Strimzi Kafka 集群示例
# 前提:已安装 Strimzi Operator,且 Operator 具备对本命名空间的 watch 权限。
apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
name: juwan-kafka
namespace: juwan # 示例业务命名空间
spec:
kafka:
version: 3.9.0 # Kafka Broker 版本
replicas: 1 # 开发环境可用;生产环境建议 >= 3
listeners:
- name: plain
port: 9092
type: internal # 仅集群内部访问
tls: false # 明文 listener,内网调试方便
- name: tls
port: 9093
type: internal
tls: true # TLS listener,推荐业务接入使用
config:
# 单副本容错参数(仅适合开发环境)
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
default.replication.factor: 1
min.insync.replicas: 1
storage:
type: ephemeral # 临时存储,Pod 重建会丢数据;生产建议 persistent-claim
zookeeper:
replicas: 1 # 开发环境可用;生产环境建议 >= 3
storage:
type: ephemeral
# 开启 Topic/User Operator,便于声明式管理 Topic 和账号
entityOperator:
topicOperator: {}
userOperator: {}
---
# 业务 Topic 示例
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaTopic
metadata:
name: user-events # 用户事件主题
namespace: juwan
labels:
strimzi.io/cluster: juwan-kafka # 关联 Kafka 集群名
spec:
partitions: 3 # 分区数,决定并行消费能力
replicas: 1 # 副本数,开发环境示例
config:
retention.ms: 604800000 # 7 天
segment.bytes: 1073741824 # 1GiB
---
# Kafka 用户与 ACL 示例
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaUser
metadata:
name: app-producer # 应用侧生产者账号
namespace: juwan
labels:
strimzi.io/cluster: juwan-kafka
spec:
authentication:
type: tls # 生成 TLS 证书凭据 Secret
authorization:
type: simple
acls:
- resource:
type: topic
name: user-events
patternType: literal
operations:
- Read
- Write
- resource:
type: group
name: app-consumer-group
patternType: literal
operations:
- Read
@@ -1,36 +0,0 @@
# MongoDB 应用用户密码示例(请改为更安全的值,或对接外部 Secret 管理)
apiVersion: v1
kind: Secret
metadata:
name: mongodb-app-user-password
namespace: juwan # 示例业务命名空间
type: Opaque
stringData:
password: ChangeMe123456 # 示例明文,仅用于演示
---
# MongoDB Community Operator 自定义资源示例
apiVersion: mongodbcommunity.mongodb.com/v1
kind: MongoDBCommunity
metadata:
name: juwan-mongodb
namespace: juwan
spec:
members: 3 # 副本集成员数,生产建议保持奇数
type: ReplicaSet
version: "7.0.12" # MongoDB 版本
security:
authentication:
modes:
- SCRAM # 启用用户名密码认证
users:
- name: app-user # 业务账号
db: admin
passwordSecretRef:
name: mongodb-app-user-password # 引用上方 Secret
roles:
- name: readWrite
db: appdb # 对 appdb 库授予读写
scramCredentialsSecretName: app-user-scram # Operator 生成的凭据 Secret
additionalMongodConfig:
# 示例:开启 WiredTiger 日志压缩
storage.wiredTiger.engineConfig.journalCompressor: zlib
@@ -1,46 +0,0 @@
# MongoDB 高可用(副本集)示例
# 说明:MongoDB 没有 Redis Sentinel 组件;副本集即其高可用机制。
apiVersion: v1
kind: Secret
metadata:
name: mongodb-ha-app-user-password
namespace: juwan
type: Opaque
stringData:
password: ChangeMe_ReallyStrongPassword
---
apiVersion: mongodbcommunity.mongodb.com/v1
kind: MongoDBCommunity
metadata:
name: juwan-mongodb-ha
namespace: juwan
spec:
members: 3
type: ReplicaSet
version: "7.0.12"
# 生产建议开启持久化(具体 storageClassName 按集群调整)
statefulSet:
spec:
volumeClaimTemplates:
- metadata:
name: data-volume
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
security:
authentication:
modes:
- SCRAM
users:
- name: app-user
db: admin
passwordSecretRef:
name: mongodb-ha-app-user-password
roles:
- name: readWrite
db: appdb
scramCredentialsSecretName: app-user-scram
additionalMongodConfig:
storage.wiredTiger.engineConfig.journalCompressor: zlib
@@ -1,218 +0,0 @@
# MongoDB 分片集群最小示例(ConfigRS + 2 个 ShardRS + Mongos
# 使用方式:
# 1) 先 apply 本文件
# 2) 按文档执行 rs.initiate / sh.addShard / sh.enableSharding
# 注意:本示例侧重结构演示,生产环境请补齐资源限制、反亲和、PDB、备份与监控。
---
apiVersion: v1
kind: Service
metadata:
name: cfg-svc
namespace: juwan
spec:
clusterIP: None
selector:
app: mongo-cfg
ports:
- name: mongo
port: 27019
targetPort: 27019
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cfg
namespace: juwan
spec:
serviceName: cfg-svc
replicas: 3
selector:
matchLabels:
app: mongo-cfg
template:
metadata:
labels:
app: mongo-cfg
spec:
containers:
- name: mongod
image: mongo:7.0
args:
[
"--configsvr",
"--replSet",
"cfg-rs",
"--port",
"27019",
"--bind_ip_all",
]
ports:
- containerPort: 27019
name: mongo
volumeMounts:
- name: data
mountPath: /data/db
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: shard1-svc
namespace: juwan
spec:
clusterIP: None
selector:
app: mongo-shard1
ports:
- name: mongo
port: 27018
targetPort: 27018
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: shard1
namespace: juwan
spec:
serviceName: shard1-svc
replicas: 3
selector:
matchLabels:
app: mongo-shard1
template:
metadata:
labels:
app: mongo-shard1
spec:
containers:
- name: mongod
image: mongo:7.0
args:
[
"--shardsvr",
"--replSet",
"shard1-rs",
"--port",
"27018",
"--bind_ip_all",
]
ports:
- containerPort: 27018
name: mongo
volumeMounts:
- name: data
mountPath: /data/db
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: shard2-svc
namespace: juwan
spec:
clusterIP: None
selector:
app: mongo-shard2
ports:
- name: mongo
port: 27018
targetPort: 27018
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: shard2
namespace: juwan
spec:
serviceName: shard2-svc
replicas: 3
selector:
matchLabels:
app: mongo-shard2
template:
metadata:
labels:
app: mongo-shard2
spec:
containers:
- name: mongod
image: mongo:7.0
args:
[
"--shardsvr",
"--replSet",
"shard2-rs",
"--port",
"27018",
"--bind_ip_all",
]
ports:
- containerPort: 27018
name: mongo
volumeMounts:
- name: data
mountPath: /data/db
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: mongos
namespace: juwan
spec:
selector:
app: mongos
ports:
- name: mongo
port: 27017
targetPort: 27017
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mongos
namespace: juwan
spec:
replicas: 2
selector:
matchLabels:
app: mongos
template:
metadata:
labels:
app: mongos
spec:
containers:
- name: mongos
image: mongo:7.0
args:
- "mongos"
- "--configdb"
- "cfg-rs/cfg-0.cfg-svc.juwan.svc.cluster.local:27019,cfg-1.cfg-svc.juwan.svc.cluster.local:27019,cfg-2.cfg-svc.juwan.svc.cluster.local:27019"
- "--bind_ip_all"
- "--port"
- "27017"
ports:
- containerPort: 27017
name: mongo
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: db-dx-init-script
namespace: juwan
labels:
app: db-dx-init-script
data:
init-extensions-sql: |
create extension if not exists "uuid-ossp";
create extension if not exists "pg_trgm";
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩社区服务"
desc: "处理帖子、评论、点赞等社区互动。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
type (
PathId {
Id int64 `path:"id"`
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩争议服务"
desc: "处理订单争议申诉与仲裁。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
type (
DisputePathId {
Id int64 `path:"id"`
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
{
"swagger": "2.0",
"info": {
"title": "聚玩争议服务",
"description": "处理订单争议申诉与仲裁。ID 字段(int64)以 string 传输",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/api/v1/disputes": {
"get": {
"summary": "获取争议列表",
"operationId": "ListDisputes",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/DisputeListResp"
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
},
{
"name": "status",
"in": "query",
"required": false,
"type": "string"
}
],
"tags": [
"dispute"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/disputes/{id}/appeal": {
"post": {
"summary": "申诉",
"operationId": "AppealDispute",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/AppealReq"
}
}
],
"tags": [
"dispute"
]
}
},
"/api/v1/disputes/{id}/response": {
"post": {
"summary": "回应争议",
"operationId": "RespondDispute",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/DisputeResponseReq"
}
}
],
"tags": [
"dispute"
]
}
},
"/api/v1/orders/{id}/dispute": {
"get": {
"summary": "获取订单争议",
"operationId": "GetOrderDispute",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/Dispute"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
}
],
"tags": [
"dispute"
]
},
"post": {
"summary": "发起争议",
"operationId": "CreateDispute",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/CreateDisputeReq"
}
}
],
"tags": [
"dispute"
]
}
}
},
"definitions": {
"AppealReq": {
"type": "object",
"properties": {
"reason": {
"type": "string"
}
},
"title": "AppealReq",
"required": [
"reason"
]
},
"CreateDisputeReq": {
"type": "object",
"properties": {
"reason": {
"type": "string"
},
"evidence": {
"type": "array",
"items": {
"type": "string"
}
}
},
"title": "CreateDisputeReq",
"required": [
"reason",
"evidence"
]
},
"Dispute": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"orderId": {
"type": "integer",
"format": "int64"
},
"initiatorId": {
"type": "integer",
"format": "int64"
},
"initiatorName": {
"type": "string"
},
"respondentId": {
"type": "integer",
"format": "int64"
},
"reason": {
"type": "string"
},
"evidence": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string"
},
"result": {
"type": "string"
},
"respondentReason": {
"type": "string"
},
"respondentEvidence": {
"type": "array",
"items": {
"type": "string"
}
},
"appealReason": {
"type": "string"
},
"appealedAt": {
"type": "string"
},
"resolvedBy": {
"type": "integer",
"format": "int64"
},
"resolvedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"title": "Dispute",
"required": [
"id",
"orderId",
"initiatorId",
"initiatorName",
"respondentId",
"reason",
"evidence",
"status",
"respondentEvidence",
"createdAt",
"updatedAt"
]
},
"DisputeListReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
},
"status": {
"type": "string"
}
},
"title": "DisputeListReq"
},
"DisputeListResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Dispute"
}
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "DisputeListResp",
"required": [
"items",
"meta"
]
},
"DisputePathId": {
"type": "object",
"title": "DisputePathId"
},
"DisputeResponseReq": {
"type": "object",
"properties": {
"reason": {
"type": "string"
},
"evidence": {
"type": "array",
"items": {
"type": "string"
}
}
},
"title": "DisputeResponseReq",
"required": [
"reason",
"evidence"
]
},
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"PageMeta": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
}
},
"title": "PageMeta",
"required": [
"total",
"offset",
"limit"
]
},
"PageReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "PageReq",
"required": [
"offset",
"limit"
]
},
"SimpleUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
}
},
"title": "SimpleUser",
"required": [
"id",
"nickname",
"avatar"
]
},
"UserProfile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"username": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
},
"role": {
"type": "string",
"description": " consumer, player, owner, admin"
},
"verifiedRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"verificationStatus": {
"type": "object"
},
"phone": {
"type": "string"
},
"bio": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "UserProfile",
"required": [
"id",
"username",
"nickname",
"avatar",
"role",
"verifiedRoles",
"verificationStatus",
"createdAt"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
+101 -83
View File
@@ -1,121 +1,139 @@
{
"swagger": "2.0",
"info": {
"title": "聚玩邮件服务",
"description": "处理邮件验证码发送与密码找回",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"version": "1.0"
},
"basePath": "/",
"paths": {
"/api/v1/auth/forgot-password/send": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "忘记密码-发送验证码",
"operationId": "emailForgotPassword",
"operationId": "ForgotPassword",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
"$ref": "#/definitions/ForgotPasswordReq"
}
}
],
"responses": {
"200": {
"description": "",
"schema": {
"type": "object"
}
}
}
"tags": [
"email"
]
}
},
"/api/v1/email/verification-code/send": {
"post": {
"description": "向用户邮箱发送验证码,支持注册、登录、重置密码、绑定邮箱等场景",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "发送邮箱验证码",
"operationId": "emailSendVerificationCode",
"description": "向用户邮箱发送验证码,支持注册、登录、重置密码、绑定邮箱等场景",
"operationId": "SendVerificationCode",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/SendVerificationCodeResp"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"email",
"scene"
],
"properties": {
"email": {
"type": "string"
},
"scene": {
"type": "string"
}
}
"$ref": "#/definitions/SendVerificationCodeReq"
}
}
],
"responses": {
"200": {
"description": "",
"schema": {
"type": "object",
"properties": {
"expireInSec": {
"type": "integer"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
}
}
}
}
"tags": [
"email"
]
}
}
},
"x-date": "2026-04-22 22:30:26",
"x-description": "This is a goctl generated swagger file.",
"x-github": "https://github.com/zeromicro/go-zero",
"x-go-zero-doc": "https://go-zero.dev/",
"x-goctl-version": "1.10.1"
"definitions": {
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"ForgotPasswordReq": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
},
"title": "ForgotPasswordReq",
"required": [
"email"
]
},
"SendVerificationCodeReq": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"scene": {
"type": "string"
}
},
"title": "SendVerificationCodeReq",
"required": [
"email",
"required",
"scene"
]
},
"SendVerificationCodeResp": {
"type": "object",
"properties": {
"requestId": {
"type": "string"
},
"expireInSec": {
"type": "integer",
"format": "int64"
},
"message": {
"type": "string"
}
},
"title": "SendVerificationCodeResp",
"required": [
"requestId",
"expireInSec",
"message"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
+241 -172
View File
@@ -1,213 +1,282 @@
{
"swagger": "2.0",
"info": {
"title": "聚玩游戏服务",
"description": "管理游戏目录与分类。ID 字段(int64)以 string 传输",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"version": "1.0"
},
"basePath": "/",
"paths": {
"/api/v1/games": {
"/api/v1/games/": {
"get": {
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "获取游戏列表",
"operationId": "gameListGames",
"parameters": [
{
"type": "integer",
"default": 0,
"example": 0,
"name": "offset",
"in": "query",
"required": true
},
{
"type": "integer",
"default": 20,
"example": 20,
"name": "limit",
"in": "query",
"required": true
}
],
"operationId": "ListGames",
"responses": {
"200": {
"description": "",
"description": "A successful response.",
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"required": [
"name",
"icon",
"category"
],
"properties": {
"category": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
}
},
"meta": {
"type": "object",
"required": [
"total",
"offset",
"limit"
],
"properties": {
"limit": {
"type": "integer"
},
"offset": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
}
}
"$ref": "#/definitions/GameListResp"
}
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"game"
],
"consumes": [
"multipart/form-data"
]
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "创建游戏",
"operationId": "gameCreateGame",
"operationId": "CreateGame",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/Game"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"name",
"icon",
"category"
],
"properties": {
"category": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
"$ref": "#/definitions/Game"
}
}
],
"responses": {
"200": {
"description": "",
"schema": {
"type": "object",
"properties": {
"category": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
}
}
}
"tags": [
"game"
]
}
},
"/api/v1/games/{id}": {
"get": {
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "获取游戏详情",
"operationId": "gameGetGame",
"parameters": [
{
"type": "integer",
"name": "id",
"in": "path",
"required": true
}
],
"operationId": "GetGame",
"responses": {
"200": {
"description": "",
"description": "A successful response.",
"schema": {
"type": "object",
"properties": {
"category": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
"$ref": "#/definitions/Game"
}
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
}
],
"tags": [
"game"
]
}
}
},
"x-date": "2026-04-22 22:30:23",
"x-description": "This is a goctl generated swagger file.",
"x-github": "https://github.com/zeromicro/go-zero",
"x-go-zero-doc": "https://go-zero.dev/",
"x-goctl-version": "1.10.1"
"definitions": {
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"Game": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"icon": {
"type": "string"
},
"category": {
"type": "string"
}
},
"title": "Game",
"required": [
"name",
"icon",
"category"
]
},
"GameListResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Game"
}
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "GameListResp",
"required": [
"items",
"meta"
]
},
"GetGameReq": {
"type": "object",
"title": "GetGameReq"
},
"PageMeta": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
}
},
"title": "PageMeta",
"required": [
"total",
"offset",
"limit"
]
},
"PageReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "PageReq",
"required": [
"offset",
"limit"
]
},
"SimpleUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
}
},
"title": "SimpleUser",
"required": [
"id",
"nickname",
"avatar"
]
},
"UserProfile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"username": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
},
"role": {
"type": "string",
"description": " consumer, player, owner, admin"
},
"verifiedRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"verificationStatus": {
"type": "object"
},
"phone": {
"type": "string"
},
"bio": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "UserProfile",
"required": [
"id",
"username",
"nickname",
"avatar",
"role",
"verifiedRoles",
"verificationStatus",
"createdAt"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
+306
View File
@@ -0,0 +1,306 @@
{
"swagger": "2.0",
"info": {
"title": "聚玩通知服务",
"description": "管理站内消息通知。ID 字段(int64)以 string 传输",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/api/v1/notifications": {
"get": {
"summary": "获取通知列表",
"operationId": "ListNotifications",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/NotificationListResp"
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"notification"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/notifications/read-all": {
"put": {
"summary": "全部已读",
"operationId": "ReadAllNotifications",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "body",
"description": " 空响应",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
],
"tags": [
"notification"
]
}
},
"/api/v1/notifications/{id}/read": {
"put": {
"summary": "标记已读",
"operationId": "ReadNotification",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/PathId"
}
}
],
"tags": [
"notification"
]
}
}
},
"definitions": {
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"Notification": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"type": {
"type": "string"
},
"title": {
"type": "string"
},
"content": {
"type": "string"
},
"read": {
"type": "boolean",
"format": "boolean"
},
"link": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "Notification",
"required": [
"id",
"type",
"title",
"content",
"read",
"createdAt"
]
},
"NotificationListResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Notification"
}
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "NotificationListResp",
"required": [
"items",
"meta"
]
},
"PageMeta": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
}
},
"title": "PageMeta",
"required": [
"total",
"offset",
"limit"
]
},
"PageReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "PageReq",
"required": [
"offset",
"limit"
]
},
"PathId": {
"type": "object",
"title": "PathId"
},
"SimpleUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
}
},
"title": "SimpleUser",
"required": [
"id",
"nickname",
"avatar"
]
},
"UserProfile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"username": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
},
"role": {
"type": "string",
"description": " consumer, player, owner, admin"
},
"verifiedRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"verificationStatus": {
"type": "object"
},
"phone": {
"type": "string"
},
"bio": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "UserProfile",
"required": [
"id",
"username",
"nickname",
"avatar",
"role",
"verifiedRoles",
"verificationStatus",
"createdAt"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
+103 -66
View File
@@ -1,95 +1,132 @@
{
"swagger": "2.0",
"info": {
"title": "文件服务",
"description": "处理文件上传与获取",
"version": "v1"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"title": "文件服务",
"version": "v1"
},
"basePath": "/",
"paths": {
"/api/v1/files": {
"get": {
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "文件获取接口 (如果是私有文件,通过此接口获取或重定向)",
"operationId": "fileGetFile",
"parameters": [
{
"type": "string",
"name": "key",
"in": "query",
"required": true
}
],
"operationId": "GetFile",
"responses": {
"200": {
"description": "",
"description": "A successful response.",
"schema": {}
}
}
},
"parameters": [
{
"name": "key",
"in": "query",
"required": true,
"type": "string"
}
],
"tags": [
"file"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/upload": {
"post": {
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "文件上传接口",
"operationId": "fileUpload",
"parameters": [
{
"enum": [
"avatar",
"chat",
"post",
"verification",
"dispute"
],
"type": "string",
"description": "文件类型限制",
"name": "type",
"in": "formData",
"required": true
}
],
"operationId": "Upload",
"responses": {
"200": {
"description": "",
"description": "A successful response.",
"schema": {
"type": "object",
"properties": {
"url": {
"description": "返回 CDN 地址或访问地址",
"type": "string"
}
}
"$ref": "#/definitions/UploadResp"
}
}
}
},
"parameters": [
{
"name": "body",
"description": " 上传请求参数(File文件流在Handler中通过 r.FormFile 获取)",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/UploadReq"
}
}
],
"tags": [
"file"
],
"consumes": [
"multipart/form-data"
]
}
}
},
"x-date": "2026-04-22 22:30:27",
"x-description": "This is a goctl generated swagger file.",
"x-github": "https://github.com/zeromicro/go-zero",
"x-go-zero-doc": "https://go-zero.dev/",
"x-goctl-version": "1.10.1"
"definitions": {
"GetFileReq": {
"type": "object",
"properties": {
"key": {
"type": "string"
}
},
"title": "GetFileReq",
"required": [
"key"
]
},
"UploadReq": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"avatar",
"chat",
"post",
"verification",
"dispute"
],
"description": " 文件类型限制"
}
},
"title": "UploadReq",
"required": [
"type"
]
},
"UploadResp": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": " 返回 CDN 地址或访问地址"
}
},
"title": "UploadResp",
"required": [
"url"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
{
"swagger": "2.0",
"info": {
"title": "聚玩评价服务",
"description": "处理订单评价与评分。ID 字段(int64)以 string 传输",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/api/v1/orders/{id}/review": {
"post": {
"summary": "提交评价",
"operationId": "SubmitReview",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/SubmitReviewReq"
}
}
],
"tags": [
"review"
]
}
},
"/api/v1/orders/{id}/reviews": {
"get": {
"summary": "获取订单评价",
"operationId": "GetOrderReviews",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/ReviewListResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "id",
"in": "query",
"required": true,
"type": "integer",
"format": "int64"
}
],
"tags": [
"review"
]
}
},
"/api/v1/reviews": {
"get": {
"summary": "获取公开评价列表",
"operationId": "ListReviews",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/ReviewListResp"
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"review"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/users/{id}/reviews": {
"get": {
"summary": "获取用户收到的评价",
"operationId": "ListUserReviews",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/ReviewListResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "id",
"in": "query",
"required": true,
"type": "integer",
"format": "int64"
},
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"review"
]
}
}
},
"definitions": {
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"GetOrderReviewsReq": {
"type": "object",
"title": "GetOrderReviewsReq"
},
"ListUserReviewsReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "ListUserReviewsReq"
},
"PageMeta": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
}
},
"title": "PageMeta",
"required": [
"total",
"offset",
"limit"
]
},
"PageReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "PageReq",
"required": [
"offset",
"limit"
]
},
"Review": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"orderId": {
"type": "integer",
"format": "int64"
},
"fromUserId": {
"type": "integer",
"format": "int64"
},
"fromUserName": {
"type": "string"
},
"rating": {
"type": "integer",
"format": "int32"
},
"content": {
"type": "string"
},
"sealed": {
"type": "boolean",
"format": "boolean"
},
"createdAt": {
"type": "string"
}
},
"title": "Review",
"required": [
"id",
"orderId",
"fromUserId",
"fromUserName",
"rating",
"content",
"sealed",
"createdAt"
]
},
"ReviewListResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Review"
}
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "ReviewListResp",
"required": [
"items",
"meta"
]
},
"ReviewPathId": {
"type": "object",
"title": "ReviewPathId"
},
"SimpleUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
}
},
"title": "SimpleUser",
"required": [
"id",
"nickname",
"avatar"
]
},
"SubmitReviewReq": {
"type": "object",
"properties": {
"rating": {
"type": "integer",
"format": "int32"
},
"content": {
"type": "string"
}
},
"title": "SubmitReviewReq",
"required": [
"rating"
]
},
"UserProfile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"username": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
},
"role": {
"type": "string",
"description": " consumer, player, owner, admin"
},
"verifiedRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"verificationStatus": {
"type": "object"
},
"phone": {
"type": "string"
},
"bio": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "UserProfile",
"required": [
"id",
"username",
"nickname",
"avatar",
"role",
"verifiedRoles",
"verificationStatus",
"createdAt"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
+550
View File
@@ -0,0 +1,550 @@
{
"swagger": "2.0",
"info": {
"title": "聚玩搜索服务",
"description": "内容搜索与首页推荐",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/api/v1/favorites": {
"get": {
"summary": "获取收藏列表",
"operationId": "ListFavorites",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/FavoriteListResp"
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"favorites"
],
"consumes": [
"multipart/form-data"
]
},
"post": {
"summary": "添加收藏",
"operationId": "AddFavorite",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/FavoriteReq"
}
}
],
"tags": [
"favorites"
]
}
},
"/api/v1/favorites/{id}": {
"delete": {
"summary": "取消收藏",
"operationId": "RemoveFavorite",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/PathIDReq"
}
}
],
"tags": [
"favorites"
]
}
},
"/api/v1/recommendations/home": {
"get": {
"summary": "首页推荐",
"operationId": "Recommendations",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/SearchResp"
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"search"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/search": {
"get": {
"summary": "统一搜索",
"operationId": "Search",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/SearchResp"
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
},
{
"name": "q",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "min",
"in": "query",
"required": false,
"type": "number",
"format": "double"
},
{
"name": "max",
"in": "query",
"required": false,
"type": "number",
"format": "double"
},
{
"name": "onlyOnline",
"in": "query",
"required": false,
"type": "boolean",
"format": "boolean"
},
{
"name": "sort",
"in": "query",
"required": false,
"type": "string"
}
],
"tags": [
"search"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/users/{id}/favorites/check": {
"get": {
"summary": "检查收藏状态",
"operationId": "CheckFavorite",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/FavoriteCheckResp"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "id",
"in": "query",
"required": true,
"type": "integer",
"format": "int64"
},
{
"name": "targetType",
"in": "query",
"required": true,
"type": "string"
},
{
"name": "targetId",
"in": "query",
"required": true,
"type": "string"
}
],
"tags": [
"favorites"
],
"consumes": [
"multipart/form-data"
]
}
}
},
"definitions": {
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"Favorite": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"userId": {
"type": "string"
},
"targetType": {
"type": "string"
},
"targetId": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "Favorite",
"required": [
"id",
"userId",
"targetType",
"targetId",
"createdAt"
]
},
"FavoriteCheckReq": {
"type": "object",
"properties": {
"targetType": {
"type": "string"
},
"targetId": {
"type": "string"
}
},
"title": "FavoriteCheckReq",
"required": [
"targetType",
"targetId"
]
},
"FavoriteCheckResp": {
"type": "object",
"properties": {
"favorited": {
"type": "boolean",
"format": "boolean"
}
},
"title": "FavoriteCheckResp",
"required": [
"favorited"
]
},
"FavoriteListResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Favorite"
}
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "FavoriteListResp",
"required": [
"items",
"meta"
]
},
"FavoriteReq": {
"type": "object",
"properties": {
"targetType": {
"type": "string",
"description": " player, shop"
},
"targetId": {
"type": "string"
}
},
"title": "FavoriteReq",
"required": [
"targetType",
"targetId"
]
},
"PageMeta": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
}
},
"title": "PageMeta",
"required": [
"total",
"offset",
"limit"
]
},
"PageReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "PageReq",
"required": [
"offset",
"limit"
]
},
"PathIDReq": {
"type": "object",
"title": "PathIDReq"
},
"SearchReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
},
"q": {
"type": "string"
},
"min": {
"type": "number",
"format": "double"
},
"max": {
"type": "number",
"format": "double"
},
"onlyOnline": {
"type": "boolean",
"format": "boolean"
},
"sort": {
"type": "string"
}
},
"title": "SearchReq"
},
"SearchResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object"
},
"description": " Mixed items"
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "SearchResp",
"required": [
"items",
"meta"
]
},
"SimpleUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
}
},
"title": "SimpleUser",
"required": [
"id",
"nickname",
"avatar"
]
},
"UserProfile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"username": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
},
"role": {
"type": "string",
"description": " consumer, player, owner, admin"
},
"verifiedRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"verificationStatus": {
"type": "object"
},
"phone": {
"type": "string"
},
"bio": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "UserProfile",
"required": [
"id",
"username",
"nickname",
"avatar",
"role",
"verifiedRoles",
"verificationStatus",
"createdAt"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
File diff suppressed because it is too large Load Diff
+864 -1013
View File
File diff suppressed because it is too large Load Diff
+281 -176
View File
@@ -1,234 +1,339 @@
{
"swagger": "2.0",
"info": {
"title": "钱包服务",
"description": "处理钱包充值相关。ID 字段(int64)以 string 传输",
"version": "1.0"
},
"schemes": [
"http",
"https"
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"title": "钱包服务",
"version": "1.0"
},
"basePath": "/",
"paths": {
"/api/v1/wallet/balance": {
"get": {
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "获取余额",
"operationId": "walletGetBalance",
"operationId": "GetBalance",
"responses": {
"200": {
"description": "",
"description": "A successful response.",
"schema": {
"type": "object",
"properties": {
"balance": {
"type": "string"
},
"frozenBalance": {
"type": "string"
}
}
"$ref": "#/definitions/WalletBalance"
}
}
}
},
"tags": [
"wallet"
]
}
},
"/api/v1/wallet/topup": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "充值",
"operationId": "walletTopup",
"operationId": "Topup",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"amount",
"method"
],
"properties": {
"amount": {
"type": "string"
},
"method": {
"type": "string"
}
}
"$ref": "#/definitions/TopupReq"
}
}
],
"responses": {
"200": {
"description": "",
"schema": {
"type": "object"
}
}
}
"tags": [
"wallet"
]
}
},
"/api/v1/wallet/transactions": {
"get": {
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "获取流水",
"operationId": "walletListTransactions",
"parameters": [
{
"type": "integer",
"default": 0,
"example": 0,
"name": "offset",
"in": "query",
"required": true
},
{
"type": "integer",
"default": 20,
"example": 20,
"name": "limit",
"in": "query",
"required": true
}
],
"operationId": "ListTransactions",
"responses": {
"200": {
"description": "",
"description": "A successful response.",
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"type",
"amount",
"description",
"createdAt"
],
"properties": {
"amount": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"description": {
"type": "string"
},
"id": {
"type": "integer"
},
"orderId": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
},
"meta": {
"type": "object",
"required": [
"total",
"offset",
"limit"
],
"properties": {
"limit": {
"type": "integer"
},
"offset": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
}
}
"$ref": "#/definitions/TransactionListResp"
}
}
}
},
"parameters": [
{
"name": "offset",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "0"
},
{
"name": "limit",
"in": "query",
"required": true,
"type": "integer",
"format": "int64",
"default": "20"
}
],
"tags": [
"wallet"
],
"consumes": [
"multipart/form-data"
]
}
},
"/api/v1/wallet/withdraw": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"https"
],
"summary": "提现",
"operationId": "walletWithdraw",
"operationId": "Withdraw",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/EmptyResp"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"amount",
"method"
],
"properties": {
"amount": {
"type": "string"
},
"method": {
"type": "string"
}
}
"$ref": "#/definitions/TopupReq"
}
}
],
"responses": {
"200": {
"description": "",
"schema": {
"type": "object"
}
}
}
"tags": [
"wallet"
]
}
}
},
"x-date": "2026-04-22 22:30:25",
"x-description": "This is a goctl generated swagger file.",
"x-github": "https://github.com/zeromicro/go-zero",
"x-go-zero-doc": "https://go-zero.dev/",
"x-goctl-version": "1.10.1"
"definitions": {
"EmptyResp": {
"type": "object",
"title": "EmptyResp"
},
"PageMeta": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"offset": {
"type": "integer",
"format": "int64"
},
"limit": {
"type": "integer",
"format": "int64"
}
},
"title": "PageMeta",
"required": [
"total",
"offset",
"limit"
]
},
"PageReq": {
"type": "object",
"properties": {
"offset": {
"type": "integer",
"format": "int64",
"default": "0"
},
"limit": {
"type": "integer",
"format": "int64",
"default": "20"
}
},
"title": "PageReq",
"required": [
"offset",
"limit"
]
},
"SimpleUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
}
},
"title": "SimpleUser",
"required": [
"id",
"nickname",
"avatar"
]
},
"TopupReq": {
"type": "object",
"properties": {
"amount": {
"type": "string"
},
"method": {
"type": "string"
}
},
"title": "TopupReq",
"required": [
"amount",
"method"
]
},
"Transaction": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"type": {
"type": "string"
},
"amount": {
"type": "string"
},
"description": {
"type": "string"
},
"orderId": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "Transaction",
"required": [
"id",
"type",
"amount",
"description",
"createdAt"
]
},
"TransactionListResp": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/Transaction"
}
},
"meta": {
"$ref": "#/definitions/PageMeta"
}
},
"title": "TransactionListResp",
"required": [
"items",
"meta"
]
},
"UserProfile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"username": {
"type": "string"
},
"nickname": {
"type": "string"
},
"avatar": {
"type": "string"
},
"role": {
"type": "string",
"description": " consumer, player, owner, admin"
},
"verifiedRoles": {
"type": "array",
"items": {
"type": "string"
}
},
"verificationStatus": {
"type": "object"
},
"phone": {
"type": "string"
},
"bio": {
"type": "string"
},
"createdAt": {
"type": "string"
}
},
"title": "UserProfile",
"required": [
"id",
"username",
"nickname",
"avatar",
"role",
"verifiedRoles",
"verificationStatus",
"createdAt"
]
},
"WalletBalance": {
"type": "object",
"properties": {
"balance": {
"type": "string"
},
"frozenBalance": {
"type": "string"
}
},
"title": "WalletBalance",
"required": [
"balance",
"frozenBalance"
]
}
},
"securityDefinitions": {
"apiKey": {
"type": "apiKey",
"description": "Enter JWT Bearer token **_only_**",
"name": "Authorization",
"in": "header"
}
}
}
+2
View File
@@ -1,6 +1,8 @@
syntax = "v1"
info (
title: "聚玩邮件服务"
desc: "处理邮件验证码发送与密码找回"
author: "Asadz"
date: "2024-06-19"
version: "1.0"
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩游戏服务"
desc: "管理游戏目录与分类。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
type (
Game {
Id int64 `json:"id,string,optional"`
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩通知服务"
desc: "管理站内消息通知。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
type (
PathId {
Id int64 `path:"id"`
+1 -1
View File
@@ -4,7 +4,7 @@ import "common.api"
info (
title: "聚玩订单服务"
desc: "处理订单业务"
desc: "处理订单业务。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
+1 -1
View File
@@ -2,7 +2,7 @@ syntax = "v1"
info (
title: "聚玩打手服务"
desc: "聚玩用户服务处理打手信息管理、服务发布及订单相关接口"
desc: "聚玩用户服务处理打手信息管理、服务发布及订单相关接口。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩评价服务"
desc: "处理订单评价与评分。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
type (
ReviewPathId {
Id int64 `path:"id"`
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩搜索服务"
desc: "内容搜索与首页推荐"
author: "Asadz"
version: "1.0"
)
type (
PathIDReq {
Id int64 `path:"id"`
+7
View File
@@ -2,6 +2,13 @@ syntax = "v1"
import "common.api"
info (
title: "聚玩店铺服务"
desc: "管理店铺信息、员工邀请、模板配置。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
type (
ShopProfile {
Id string `json:"id"`
+1 -1
View File
@@ -2,7 +2,7 @@ syntax = "v1"
info (
title: "聚玩用户服务"
desc: "处理用户注册、登录、个人信息管理及关注系统"
desc: "处理用户注册、登录、个人信息管理及关注系统。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
+1 -1
View File
@@ -2,7 +2,7 @@ syntax = "v1"
info (
title: "钱包服务"
desc: "处理钱包充值相关"
desc: "处理钱包充值相关。ID 字段(int64)以 string 传输"
author: "Asadz"
version: "1.0"
)
-230
View File
@@ -1,230 +0,0 @@
# Envoy ext_authz 适配 user-rpc.ValidateToken(最小实现)
## 目标
- Envoy 不直接调用业务 proto 方法。
- 新增一个内部服务 `authz-adapter`,实现 Envoy 标准 gRPC 鉴权接口。
- `authz-adapter` 再调用现有 `user-rpc.ValidateToken` 完成会话态二次校验。
---
## 1) 最小接口定义(Envoy 标准)
`authz-adapter` 需要实现的是 Envoy 官方服务:
- gRPC Service: `envoy.service.auth.v3.Authorization`
- RPC Method: `Check(CheckRequest) returns (CheckResponse)`
Go 里通常使用包:
- `github.com/envoyproxy/go-control-plane/envoy/service/auth/v3`
- `github.com/envoyproxy/go-control-plane/envoy/type/v3`
---
## 2) 最小 Go 适配器骨架
```go
package main
import (
"context"
"net"
"strconv"
"strings"
core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
"google.golang.org/grpc"
userpb "juwan-backend/app/users/rpc/pb"
)
type server struct {
authv3.UnimplementedAuthorizationServer
userRpc userpb.UsercenterClient
}
func (s *server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) {
attrs := req.GetAttributes()
httpReq := attrs.GetRequest().GetHttp()
if httpReq == nil {
return deny(403, "missing http attributes"), nil
}
path := httpReq.GetPath()
// 放行公共接口(探针、登录/注册、发送验证码)
if path == "/healthz" ||
path == "/api/v1/auth/login" ||
path == "/api/v1/auth/register" ||
path == "/api/v1/auth/forgot-password" ||
path == "/api/v1/auth/reset-password" ||
path == "/api/v1/auth/forgot-password/send" ||
path == "/api/v1/email/verification-code/send" {
return allow(nil), nil
}
token := extractCookie(httpReq.GetHeaders(), "JToken")
if token == "" {
return deny(401, "missing token cookie"), nil
}
userIDHeader := getHeader(httpReq.GetHeaders(), "x-auth-user-id")
if userIDHeader == "" {
return deny(401, "missing x-auth-user-id header"), nil
}
userID, err := strconv.ParseInt(userIDHeader, 10, 64)
if err != nil {
return deny(401, "invalid x-auth-user-id"), nil
}
// 调用现有业务 RPC 做会话态二次校验
vt, err := s.userRpc.ValidateToken(ctx, &userpb.ValidateTokenReq{
Token: token,
UserId: userID,
})
if err != nil || vt == nil || !vt.Valid {
return deny(401, "invalid token"), nil
}
// 透传给后端 API
headers := []*core.HeaderValueOption{
{
Header: &core.HeaderValue{Key: "x-auth-user-id", Value: strconv.FormatInt(vt.UserId, 10)},
},
{
Header: &core.HeaderValue{Key: "x-auth-role-type", Value: vt.RoleType},
},
}
return allow(headers), nil
}
func allow(headers []*core.HeaderValueOption) *authv3.CheckResponse {
return &authv3.CheckResponse{
Status: &typev3.Status{Code: int32(typev3.Code_OK)},
HttpResponse: &authv3.CheckResponse_OkResponse{
OkResponse: &authv3.OkHttpResponse{Headers: headers},
},
}
}
func deny(code int32, msg string) *authv3.CheckResponse {
return &authv3.CheckResponse{
Status: &typev3.Status{Code: int32(typev3.Code_PERMISSION_DENIED)},
HttpResponse: &authv3.CheckResponse_DeniedResponse{
DeniedResponse: &authv3.DeniedHttpResponse{
Status: &typev3.HttpStatus{Code: typev3.StatusCode(code)},
Body: "{\"code\":" + strconv.Itoa(int(code)) + ",\"message\":\"" + msg + "\"}",
Headers: []*core.HeaderValueOption{
{Header: &core.HeaderValue{Key: "content-type", Value: "application/json"}},
},
},
},
}
}
func extractCookie(headers map[string]string, name string) string {
c := headers["cookie"]
parts := strings.Split(c, ";")
for _, p := range parts {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) == 2 && kv[0] == name {
return kv[1]
}
}
return ""
}
func getHeader(headers map[string]string, name string) string {
for k, v := range headers {
if strings.EqualFold(k, name) {
return v
}
}
return ""
}
func main() {
lis, _ := net.Listen("tcp", ":9002")
grpcServer := grpc.NewServer()
// TODO: 创建 user-rpc client 并注入
// userRpcClient := ...
authv3.RegisterAuthorizationServer(grpcServer, &server{userRpc: nil})
_ = grpcServer.Serve(lis)
}
```
---
## 3) Envoy 最小配置片段(插入现有 `http_filters`
`envoy.filters.http.router` 之前加入:
```yaml
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
failure_mode_allow: false
with_request_body:
max_request_bytes: 8192
allow_partial_message: true
grpc_service:
envoy_grpc:
cluster_name: authz_adapter_cluster
timeout: 0.5s
```
并在 `clusters` 中加入:
```yaml
- name: authz_adapter_cluster
connect_timeout: 0.5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: authz_adapter_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: authz-adapter-svc.juwan.svc.cluster.local
port_value: 9002
http2_protocol_options: {}
```
> gRPC 上游必须启用 `http2_protocol_options: {}`
---
## 4) 落地清单
1. 新建 `authz-adapter` 服务(Deployment + Service)。
2. Adapter 内部连 `user-rpc-svc:9001`,调用 `ValidateToken`
3. Envoy 加 `ext_authz` filter + `authz_adapter_cluster`
4. 明确失败语义:
- 无 token -> 401
- token 无效/过期 -> 401
- 权限不足 -> 403
5. `jwt_authn` 先注入 `x-auth-user-id``x-auth-is-admin`,再由 adapter 透传 `x-auth-user-id``x-auth-role-type`
6. 灰度建议:先仅对 `/api/v1/auth``/api/v1/email` 验证链路,再逐步扩展到其它业务路径。
> 实践建议:若保留 K8s `readiness/liveness` 探针使用 `/healthz`,请确保该路径在 `ext_authz` 上也放行,否则会出现探针 403 导致 Pod 重启。
---
## 5) 与当前 `jwt_authn` 的关系
- 可以并存:
- 先 `jwt_authn` 快速验签并注入 claim header
- 再 `ext_authz` 做 Redis 会话态、黑名单、细粒度权限
- 也可以只保留 `ext_authz`(由 adapter 内完成全部逻辑)。
推荐:**先并存**,稳定后再决定是否简化。
-817
View File
@@ -1,817 +0,0 @@
# Envoy Gateway 配置指南(带 JWT 认证)
## 📋 目录
1. [快速开始](#快速开始)
2. [添加新服务](#添加新服务)
3. [JWT 认证配置](#jwt-认证配置)
4. [分级访问控制](#分级访问控制)
5. [故障排查](#故障排查)
6. [当前实现说明(与仓库配置对齐)](#当前实现说明与仓库配置对齐)
7. [ext_authz 适配方案](#ext_authz-适配方案)
8. [前端接入示例(邮箱验证码)](#前端接入示例邮箱验证码)
---
## 当前实现说明(与仓库配置对齐)
> 本节对应当前实际配置文件:`deploy/k8s/envoy/envoy.yaml`
### 0) 当前公共路由(不需要登录)
当前网关对以下路径做了“公共放行”:
- `/healthz`(直返 200,用于探针)
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/forgot-password`
- `POST /api/v1/auth/reset-password`
- `POST /api/v1/auth/forgot-password/send`
- `POST /api/v1/email/verification-code/send`
此外,当前配置还对一批只读接口做了 JWT 白名单豁免,例如 `GET /api/v1/games*``GET /api/v1/players*``GET /api/v1/services*``GET /api/v1/posts*``GET /api/v1/shops*` 以及部分 `GET /api/v1/users/*` 子路径。精确范围以 `deploy/k8s/envoy/envoy.yaml` 中的 `jwt_authn.rules` 为准。
实现方式:
- 在 `jwt_authn.rules` 中将上述路径加入白名单(不要求 JWT)
- 在路由层对上述路径关闭 `ext_authz`(避免公共接口和探针被二次鉴权拦截)
### 1) 用户认证后,`UserId` 放在哪里?
当前 Envoy 使用 `envoy.filters.http.jwt_authn` 做 JWT 校验,校验通过后通过 `claim_to_headers` 将 claim 注入到转发请求头:
- `UserId` -> `x-auth-user-id`
- `IsAdmin` -> `x-auth-is-admin`
也就是说,后端 API(如 user-api/email-api)拿到的是 HTTP Header,不是 Envoy 动态元数据。
### 2) 当前配置是否实现了“换票”(token renew)?
没有。
当前 Envoy 配置仅负责:
- 从 Cookie `JToken` 提取 JWT
- 用 HS256 + `issuer: juwan-user-rpc` 验签与过期检查
当 token 过期时,`jwt_authn` 会直接拒绝请求,不会调用 user-rpc 的 `JwtManager.Renew`
### 3) Envoy 能否直接调用 `user-rpc.ValidateToken`
结论:不能“直接”用现有 `ValidateToken` protobuf 接口接入 Envoy 认证链。
原因:
- Envoy 内置认证过滤器(如 `jwt_authn``ext_authz`)要求固定协议。
- `ext_authz` 的 gRPC 需要实现 Envoy 标准服务 `envoy.service.auth.v3.Authorization`,不是业务自定义的 `pb.usercenter/ValidateToken`
当前仓库已经采用 `authz-adapter` 方案:Envoy 先做 `jwt_authn`,再通过 `ext_authz` 调用 `authz-adapter`,由它内部调用 `user-rpc.ValidateToken` 做会话态二次校验。
当前链路包含:
- `authz-adapter` 服务(实现 Envoy `CheckRequest/CheckResponse`
- Envoy `ext_authz` filter 与 `authz_adapter_cluster`
- `jwt_authn` 注入 `x-auth-user-id``x-auth-is-admin`
- `authz-adapter` 透传 `x-auth-user-id``x-auth-role-type`
- 失败码与错误体规范(401/403)
### 4) 与你现有 `ValidateTokenLogic` 的一致性提醒
当前 `app/users/rpc/internal/logic/validateTokenLogic.go` 中:
- `JwtManager.Valid()` 负责验证 JWT 字符串本身
- 当前逻辑还会校验 JWT payload 中的 `UserId` 与请求传入的 `userId` 一致,再查询数据库回填 `RoleType`
这意味着当前 `ext_authz -> user-rpc.ValidateToken` 链已经具备 token 有效性和 userId 一致性校验。
---
## ext_authz 适配方案
如果你希望 Envoy 在鉴权阶段调用 `user-rpc.ValidateToken`,请看完整落地文档:
- [ENVOY_EXT_AUTHZ_ADAPTER.md](ENVOY_EXT_AUTHZ_ADAPTER.md)
该文档包含:
- Envoy 标准 `Authorization.Check` 最小实现骨架
- 调用 `user-rpc.ValidateToken` 的适配逻辑示例
- 可直接嵌入现有网关的 `ext_authz` filter + cluster 配置片段
---
## 前端接入示例(邮箱验证码)
以下示例基于当前 K8s 网关配置:
- 登录:`POST /api/v1/auth/login`(公共放行)
- 发送验证码:`POST /api/v1/email/verification-code/send`(公共放行,无需登录)
- CSRF 头:`xsrf-token`(请求头)
- CSRF Cookie`__Host-XSRF-TOKEN`(可读)
- CSRF Guard Cookie`__Host-XSRF-GUARD``HttpOnly`
- JWT Cookie`JToken``HttpOnly`,前端不可读,但会随请求自动携带)
> 注意:当前 Envoy 给 CSRF Cookie 设置了 `Secure` + `SameSite=Strict`。前端必须走 **HTTPS 且同站点** 才能稳定工作。
### 接入流程
1. 先发一个安全方法请求(GET),让 Envoy 下发 XSRF 双 Cookie。
2. 注册场景可直接调用发送验证码接口,仅需携带 `xsrf-token`
3. 登录场景可先调用登录接口拿 `JToken`,后续访问受保护接口时自动携带。
### 前端示例(TypeScript + fetch
```ts
const API_BASE = "https://your-gateway-domain";
function getCookie(name: string): string {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : "";
}
async function primeXsrfCookies() {
await fetch(`${API_BASE}/healthz`, {
method: "GET",
credentials: "include",
});
}
async function login(username: string, password: string) {
const xsrfToken = getCookie("__Host-XSRF-TOKEN");
const res = await fetch(`${API_BASE}/api/v1/auth/login`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"xsrf-token": xsrfToken,
},
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`login failed: ${res.status} ${text}`);
}
return res.json();
}
type SendCodeReq = {
email: string;
scene: "register" | "login" | "reset_password" | "bind_email";
};
async function sendVerificationCode(req: SendCodeReq) {
const xsrfToken = getCookie("__Host-XSRF-TOKEN");
const res = await fetch(`${API_BASE}/api/v1/email/verification-code/send`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"xsrf-token": xsrfToken,
},
body: JSON.stringify(req),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`send code failed: ${res.status} ${text}`);
}
return res.json() as Promise<{
requestId: string;
expireInSec: number;
message: string;
}>;
}
// 页面初始化时建议执行一次
await primeXsrfCookies();
// 注册场景:无需登录即可发送验证码
const data = await sendVerificationCode({
email: "alice@example.com",
scene: "register",
});
console.log("code request:", data);
// 如需调用受保护接口,再执行登录
await login("alice", "P@ssw0rd");
```
### 常见前端坑位
- 必须加 `credentials: "include"`,否则 Cookie 不会带上。
- `JToken``HttpOnly`,前端读不到,这是正常设计。
- 如果你前后端跨站点,`SameSite=Strict` 会导致 Cookie 不发送;需要改网关 Cookie 策略。
- 本地 `http://localhost` 下,`Secure` Cookie 不会生效;建议本地也走 HTTPS(例如反向代理或证书)。
---
## 快速开始
### 前置条件
- K8s 集群正在运行(已验证 ✅)
- Envoy Gateway Pod 处于 Running 状态
- 所有后端服务已部署
### 当前网关状态
```bash
# 查看 Envoy Pod
kubectl get pods -n juwan -l app=envoy-gateway
# 查看网关 Service
kubectl get svc -n juwan envoy-gateway
# 查看 ConfigMap
kubectl get cm -n juwan envoy-config
```
### 访问网关
```bash
# 通过 kubectl 端口转发(本地测试)
kubectl port-forward -n juwan svc/envoy-gateway 8080:80 &
# 测试
curl http://localhost:8080/healthz
```
---
## 添加新服务
### 场景:添加 Product 服务
#### 1. 创建服务的 K8s 部署清单
编辑或创建 `deploy/k8s/service/product/product-api.yaml`
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: juwan
---
apiVersion: v1
kind: ConfigMap
metadata:
name: product-api-config
namespace: juwan
data:
product-api.yaml: |
Name: product-api
Host: 0.0.0.0
Port: 8890
Database:
DataSource: postgres://user:pass@pg-dx:5432/juwan
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: product-api
namespace: juwan
labels:
app: product-api
spec:
replicas: 2
selector:
matchLabels:
app: product-api
template:
metadata:
labels:
app: product-api
spec:
containers:
- name: api
image: your-registry/product-api:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8890
name: http
volumeMounts:
- name: config
mountPath: /etc/product-api
env:
- name: TZ
value: "Asia/Shanghai"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: 8890
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: config
configMap:
name: product-api-config
---
apiVersion: v1
kind: Service
metadata:
name: product-api-svc
namespace: juwan
spec:
selector:
app: product-api
ports:
- port: 8890
targetPort: 8890
name: http
type: ClusterIP
```
#### 2. 在 Envoy 网关中添加路由
编辑 `deploy/k8s/envoy-gateway.yaml`,在 `route_config``routes` 部分添加:
```yaml
# ... 在现有路由下方添加:
- match:
prefix: /api/products
route:
cluster: product_api_cluster
timeout: 30s
```
#### 3. 在 Envoy 网关中添加上游集群
编辑 `deploy/k8s/envoy-gateway.yaml`,在 `clusters` 部分添加:
```yaml
- name: product_api_cluster
connect_timeout: 5s
type: STRICT_DNS
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: product_api_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: product-api-svc.juwan.svc.cluster.local
port_value: 8890
health_checks:
- timeout: 3s
interval: 10s
unhealthy_threshold: 2
healthy_threshold: 2
http_health_check:
path: /health
```
#### 4. 部署到集群
```bash
# 部署 Product API
kubectl apply -f deploy/k8s/service/product/product-api.yaml
# 更新 Envoy 配置
kubectl apply -f deploy/k8s/envoy-gateway.yaml
# 重启 Envoy Pod 以加载新配置
kubectl delete pods -n juwan -l app=envoy-gateway
# 验证
kubectl get pods -n juwan
# 测试新接口
curl http://localhost:8080/api/products
```
---
## JWT 认证配置
### 1. 生成 JWT 密钥并存储
```bash
# 执行设置脚本
bash deploy/envoy/setup-jwt-auth.sh
# 或手动执行
JWT_SECRET=$(openssl rand -hex 32)
echo "保存这个密钥: $JWT_SECRET"
# 创建 K8s Secret
kubectl create secret generic jwt-secret \
--from-literal=key=$JWT_SECRET \
-n juwan
```
### 2. 配置 Envoy JWT 认证
编辑 `deploy/k8s/envoy-gateway.yaml`,更新 `http_filters` 部分:
```yaml
http_filters:
# JWT 认证过滤器(必须在 router 之前)
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
default:
issuer: "juwan"
audiences: "api"
# 使用 ConfigMap 中的 JWKS(已通过 volumeMount 挂载)
local_jwks:
filename: /etc/envoy/jwks.json
rules:
# 规则1: 登录端点不需要认证
- match:
prefix: /api/users/login
allow_missing_or_failed: true
# 规则2: 注册端点不需要认证
- match:
prefix: /api/users/register
allow_missing_or_failed: true
# 规则3: 获取公开商品列表不需要认证
- match:
prefix: /api/products
case_sensitive: false
methods: ["GET"] # 仅 GET 不需要认证
allow_missing_or_failed: true
# 规则4: 其他所有路由需要认证
- match:
prefix: "/"
requires:
provider_name: "default"
# 路由过滤器(在 JWT 认证之后)
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
```
### 3. 在 Envoy Deployment 中挂载 JWKS
编辑 `deploy/k8s/envoy-gateway.yaml` 的 Deployment 部分:
```yaml
spec:
# ... 其他配置 ...
template:
spec:
containers:
- name: envoy
# ... 其他配置 ...
volumeMounts:
- name: envoy-config
mountPath: /etc/envoy
- name: jwks-config # ← 新增
mountPath: /etc/envoy
volumes:
- name: envoy-config
configMap:
name: envoy-config
- name: jwks-config # ← 新增
configMap:
name: jwks-config
items:
- key: jwks.json
path: jwks.json
```
### 4. 在 API 服务中生成 JWT Token
在 User API 的 login 端点(`app/users/api/internal/logic/user/loginlogic.go`):
```go
package user
import (
"context"
"time"
"github.com/golang-jwt/jwt/v4"
"app/users/api/internal/svc"
"app/users/api/internal/types"
)
type LoginLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func (l *LoginLogic) Login(req *types.LoginReq) (*types.LoginResp, error) {
// TODO: 验证用户名和密码
// 从配置中获取 JWT 密钥
jwtSecret := l.svcCtx.Config.JwtSecret
if jwtSecret == "" {
jwtSecret = "default-secret" // 开发环境默认值
}
// 生成 JWT Token
claims := jwt.MapClaims{
"userId": 1, // 实际应从数据库获取
"username": req.Username,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(jwtSecret))
if err != nil {
return nil, err
}
return &types.LoginResp{
Token: tokenString,
Expires: time.Now().Add(24 * time.Hour).Unix(),
UserId: 1,
Username: req.Username,
Email: "user@example.com",
}, nil
}
```
### 5. 在 API 配置中设置 JWT 密钥
编辑 `app/users/api/etc/user-api.yaml`
```yaml
Name: user-api
Host: 0.0.0.0
Port: 8888
JwtSecret: "${JWT_SECRET}" # 环境变量
Database:
DataSource: postgres://...
UserRpc:
Endpoints:
- user-rpc-svc.juwan.svc.cluster.local:50051
```
编辑 `app/users/api/internal/config/config.go`
```go
package config
import "github.com/zeromicro/go-zero/rest"
type Config struct {
rest.RestConf
JwtSecret string `json:"jwtSecret"`
}
```
在 K8s Deployment 中设置环境变量:
```yaml
spec:
template:
spec:
containers:
- name: api
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: jwt-secret
key: key
```
---
## 分级访问控制
### 场景1: 获取用户信息(有权限区分)
如果用户查看自己的信息 → 返回完整数据
如果用户查看他人信息 → 返回部分数据
#### 在 RPC 服务中实现
编辑 `app/users/rpc/internal/logic/getUsersByIdLogic.go`
```go
func (l *GetUsersByIdLogic) GetUsersById(ctx context.Context, in *pb.GetUsersByIdReq) (*pb.GetUsersByIdResp, error) {
// 获取请求者的 userId(由 API 层通过 context 传递)
requesterID, ok := ctx.Value("userId").(int64)
if !ok {
requesterID = 0 // 未认证用户
}
targetID := in.Id
// 查询数据库
user := l.svcCtx.UserModel.FindOne(ctx, targetID)
if user == nil {
return nil, status.Error(codes.NotFound, "user not found")
}
resp := &pb.GetUsersByIdResp{
Users: &pb.Users{
UserId: user.UserId,
Username: user.Username,
CreatedAt: user.CreatedAt,
},
}
// 权限检查:自己可以看全部,别人只能看部分
if requesterID == targetID {
resp.Users.Email = user.Email // ✅ 自己可见
resp.Users.Phone = user.Phone // ✅ 自己可见
resp.Users.Passwd = "" // ❌ 密码永远不返回
}
// else: 只返回基本信息(username, userId
return resp, nil
}
```
#### 在 API 层调用时传递 userId
编辑 `app/users/api/internal/logic/user/getUserInfoLogic.go`
```go
func (l *GetUserInfoLogic) GetUserInfo(req *types.GetUserInfoReq) (*types.UserInfo, error) {
// 从 context 获取当前认证用户
currentUserID, ok := l.ctx.Value("userId").(int64)
if !ok {
// 未认证 → 只能查看公开信息
currentUserID = 0
}
// 调用 RPC,传递 userId
ctx := context.WithValue(l.ctx, "userId", currentUserID)
rpcResp, err := l.svcCtx.UserRpc.GetUsersById(ctx, &pb.GetUsersByIdReq{
Id: req.UserId,
})
if err != nil {
return nil, err
}
return &types.UserInfo{
UserId: rpcResp.Users.UserId,
Username: rpcResp.Users.Username,
Email: rpcResp.Users.Email,
Phone: rpcResp.Users.Phone,
CreateAt: rpcResp.Users.CreatedAt,
}, nil
}
```
### 场景2: 修改用户信息(只能修改自己)
```go
func (l *UpdateUserInfoLogic) UpdateUserInfo(req *types.UpdateUserInfoReq) (*types.UpdateUserInfoResp, error) {
// 获取当前认证用户
currentUserID, ok := l.ctx.Value("userId").(int64)
if !ok {
return nil, errors.New("unauthorized")
}
// 权限检查:只能修改自己的信息
if currentUserID != req.UserId {
return nil, errors.New("forbidden: can only update your own info")
}
// 更新用户信息
// ...
return &types.UpdateUserInfoResp{
Message: "更新成功",
}, nil
}
```
---
## 故障排查
### 问题1: Envoy Pod 启动失败
```bash
# 查看日志
kubectl logs -n juwan -l app=envoy-gateway --tail=100
# 常见错误及解决
# Error: "no such field"
# → YAML 字段名拼写错误或与 Envoy 版本不兼容
# → 检查 Envoy 版本并查看官方文档
# Error: "unknown cluster"
# → envoy-gateway.yaml 中缺少 cluster 定义
# → 确保添加了所有需要的 cluster 部分
# Error: "unknown extension type"
# → 使用了 Envoy 不支持的扩展类型
# → 检查 "@type" 字段是否正确
```
### 问题2: JWT 认证失败
```bash
# 验证 JWKS ConfigMap 是否存在
kubectl get cm -n juwan jwks-config
# 查看 JWKS 内容
kubectl get cm jwks-config -n juwan -o jsonpath='{.data.jwks\.json}'
# 验证 Envoy 能否读取 JWKS
kubectl exec -it {envoy-pod-name} -n juwan -- ls -la /etc/envoy/
# 测试没有 Token 的请求(应返回 401)
curl -v http://localhost/api/users/1
# 测试有效 Token 的请求
TOKEN="your-jwt-token"
curl -H "Authorization: Bearer $TOKEN" http://localhost/api/users/1
```
### 问题3: 后端服务无法访问
```bash
# 查看 Service 是否存在
kubectl get svc -n juwan
# 测试 DNS 解析
kubectl exec -it {pod-name} -n juwan -- \
nslookup product-api-svc.juwan.svc.cluster.local
# 查看 Pod 是否正确运行
kubectl get pods -n juwan -l app=product-api
# 查看后端服务日志
kubectl logs -n juwan -l app=product-api --tail=50
# Envoy 检查上游集群状态
kubectl exec -it {envoy-pod-name} -n juwan -- \
curl localhost:9901/clusters | grep -A5 product_api_cluster
```
### 问题4: 跨域请求失败
如果前端遇到 CORS 问题:
```yaml
# 在 Envoy 配置中添加 CORS 过滤器
http_filters:
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
# JWT 认证过滤器(在 CORS 之后)
- name: envoy.filters.http.jwt_authn
# ...
```
---
## 配置更新流程
每次修改 `envoy-gateway.yaml` 后的完整更新步骤:
```bash
# 1. 验证 YAML 语法
kubectl apply -f deploy/k8s/envoy-gateway.yaml --dry-run=client
# 2. 应用配置
kubectl apply -f deploy/k8s/envoy-gateway.yaml
# 3. 监控 Pod 重启(应该自动重新加载)
kubectl get pods -n juwan -l app=envoy-gateway -w
# 4. 查看最新日志确认无错误
kubectl logs -n juwan -l app=envoy-gateway --tail=50
# 5. 测试新配置
curl http://localhost/api/your-new-endpoint
```
---
## 总结
| 任务 | 文件 | 说明 |
|-----|------|------|
| 添加新 API | `desc/api/`, `app/*/api/` | 定义接口并实现业务逻辑 |
| 添加新 RPC | `desc/rpc/`, `app/*/rpc/` | 内部服务通信(不通过网关) |
| 更新网关路由 | `deploy/k8s/envoy-gateway.yaml` | 添加路由、集群、认证规则 |
| 配置认证 | `deploy/envoy/setup-jwt-auth.sh` | 生成和管理 JWT 密钥 |
| 部署到 K8s | `deploy/k8s/service/` | 创建服务的 Deployment 和 Service |
需要更多帮助?查看 `PROJECT_GUIDE.md` 了解完整的项目架构和工作流!
-601
View File
@@ -1,601 +0,0 @@
# JWT 集成指南
指导如何将 JWT Manager 集成到 RPC Handlers 和业务逻辑中。
## 1. gRPC Unary Interceptor 实现
在 RPC 服务中添加 JWT 验证拦截器。
### 创建拦截器
创建文件 [app/users/rpc/internal/interceptor/jwt_interceptor.go](../../../app/users/rpc/internal/interceptor/jwt_interceptor.go)
```go
package interceptor
import (
"context"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"yourmodule/app/users/rpc/internal/svc"
)
// JwtUnaryInterceptor 验证 gRPC 请求中的 JWT 令牌
func JwtUnaryInterceptor(svcCtx *svc.ServiceContext) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// 获取请求元数据
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
// 从 Authorization 头提取令牌
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization header")
}
token := tokens[0]
// 验证令牌
claims, err := svcCtx.JwtManager.Valid(ctx, token)
if err != nil {
log.Printf("Token validation failed: %v", err)
// 尝试刷新令牌(如果过期但仍在 Redis 中)
newToken, refreshErr := svcCtx.JwtManager.Renew(ctx, token)
if refreshErr == nil && newToken != "" {
// 在响应头中返回新令牌
grpc.SetHeader(ctx, metadata.Pairs("authorization", newToken))
// 继续处理请求,使用原令牌的声明
// 注意:实际应用中需要重新验证新令牌
newClaims, err := svcCtx.JwtManager.Valid(ctx, newToken)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "token refresh failed")
}
claims = newClaims
} else {
return nil, status.Error(codes.Unauthenticated, "invalid or expired token")
}
}
// 将声明附加到上下文,供处理器使用
newCtx := context.WithValue(ctx, "claims", claims)
return handler(newCtx, req)
}
}
// JwtStreamInterceptor 验证流式 gRPC 请求中的 JWT 令牌
func JwtStreamInterceptor(svcCtx *svc.ServiceContext) grpc.StreamServerInterceptor {
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
md, ok := metadata.FromIncomingContext(ss.Context())
if !ok {
return status.Error(codes.Unauthenticated, "missing metadata")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return status.Error(codes.Unauthenticated, "missing authorization header")
}
token := tokens[0]
claims, err := svcCtx.JwtManager.Valid(ss.Context(), token)
if err != nil {
return status.Error(codes.Unauthenticated, "invalid token")
}
// 创建包装流以注入上下文
wrappedStream := &WrappedStream{
ServerStream: ss,
ctx: context.WithValue(ss.Context(), "claims", claims),
}
return handler(srv, wrappedStream)
}
}
// WrappedStream 包装 grpc.ServerStream 以注入新的上下文
type WrappedStream struct {
grpc.ServerStream
ctx context.Context
}
func (w *WrappedStream) Context() context.Context {
return w.ctx
}
```
### 在 Server 中注册拦截器
修改 [app/users/rpc/usercenter/usercenter.go](../app/users/rpc/usercenter/usercenter.go)
```go
package main
import (
"flag"
"fmt"
"log"
"yourmodule/app/users/rpc/internal/config"
"yourmodule/app/users/rpc/internal/interceptor"
"yourmodule/app/users/rpc/internal/server"
"yourmodule/app/users/rpc/internal/svc"
"yourmodule/app/users/rpc/pb"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/core/logx"
"google.golang.org/grpc"
)
var configFile = flag.String("f", "etc/pb.yaml", "the config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
ctx := svc.NewServiceContext(c)
logx.DisableStat()
s := grpc.NewServer(
grpc.UnaryInterceptor(interceptor.JwtUnaryInterceptor(ctx)),
grpc.StreamInterceptor(interceptor.JwtStreamInterceptor(ctx)),
)
pb.RegisterUsercenterServer(s, server.NewUsercenterServer(ctx))
logx.Infof("Starting gRPC server on %s:%d", c.Host, c.Port)
if err := s.Serve(net.Listen("tcp", "0.0.0.0:"+fmt.Sprintf("%d", c.Port))); err != nil {
logx.Error(err)
}
}
```
## 2. 登录 Handler 实现
实现 [app/users/api/internal/handler/user/loginHandler.go](../../../app/users/api/internal/handler/user/loginHandler.go)
```go
package user
import (
"context"
"log"
"net/http"
"yourmodule/app/users/api/internal/logic/user"
"yourmodule/app/users/api/internal/svc"
"yourmodule/app/users/api/internal/types"
)
// LoginHandler 处理用户登录
func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.LoginRequest
// 解析请求体...
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// 调用业务逻辑
resp, err := user.NewLoginLogic(r.Context(), svcCtx).Login(&req)
if err != nil {
log.Printf("Login failed: %v", err)
http.Error(w, "Login failed", http.StatusUnauthorized)
return
}
// 返回令牌
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}
```
实现 [app/users/api/internal/logic/user/loginLogic.go](../../../app/users/api/internal/logic/user/loginLogic.go)
```go
package user
import (
"context"
"errors"
"yourmodule/app/users/api/internal/svc"
"yourmodule/app/users/api/internal/types"
)
type LoginLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
return &LoginLogic{
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *LoginLogic) Login(req *types.LoginRequest) (*types.LoginResponse, error) {
// 1. 验证用户凭证(密码等)
user, err := l.svcCtx.UserModel.FindByEmail(l.ctx, req.Email)
if err != nil {
return nil, errors.New("user not found")
}
// 2. 验证密码
if !user.VerifyPassword(req.Password) {
return nil, errors.New("invalid password")
}
// 3. 生成 JWT 令牌
token, err := l.svcCtx.JwtManager.New(
l.ctx,
user.ID,
user.Email,
user.Name,
)
if err != nil {
return nil, errors.New("failed to generate token")
}
// 4. 返回令牌
return &types.LoginResponse{
Token: token,
User: types.User{
ID: user.ID,
Email: user.Email,
Name: user.Name,
},
}, nil
}
```
## 3. 在 Handlers 中使用声明
在 Protected Handlers 中提取并使用声明:
```go
package user
import (
"context"
"log"
"net/http"
"yourmodule/app/users/api/internal/svc"
"yourmodule/app/users/api/internal/types"
"github.com/golang-jwt/jwt/v4"
)
// GetUserInfoHandler 获取当前用户信息
func GetUserInfoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 从上下文提取声明(由拦截器设置)
claims, ok := r.Context().Value("claims").(*jwt.RegisteredClaims)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 使用声明中的用户信息
userID := claims.Subject // 用户 ID 存储在 Subject 中
log.Printf("User %s requested their info", userID)
// 查询用户信息
user, err := svcCtx.UserModel.FindByID(r.Context(), userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// 返回用户信息
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
}
```
## 4. 令牌刷新端点
实现令牌刷新端点:
```go
package user
import (
"net/http"
"yourmodule/app/users/api/internal/svc"
"yourmodule/app/users/api/internal/types"
)
// RefreshTokenHandler 刷新过期的 JWT 令牌
func RefreshTokenHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// 提取旧令牌
oldToken := req.Token
// 尝试刷新令牌
newToken, err := svcCtx.JwtManager.Renew(r.Context(), oldToken)
if err != nil {
http.Error(w, "Token refresh failed", http.StatusUnauthorized)
return
}
// 返回新令牌
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(types.RefreshTokenResponse{
Token: newToken,
})
}
}
```
## 5. 登出处理
实现登出端点以撤销令牌:
```go
package user
import (
"net/http"
"yourmodule/app/users/api/internal/svc"
)
// LogoutHandler 登出用户(撤销令牌)
func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 从上下文提取声明
claims, ok := r.Context().Value("claims").(*jwt.RegisteredClaims)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID := claims.Subject
// 获取用户当前令牌
currentToken := r.Header.Get("Authorization")
// 撤销令牌
err := svcCtx.JwtManager.Revoke(r.Context(), userID, currentToken)
if err != nil {
http.Error(w, "Logout failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "logged out successfully"})
}
}
```
## 6. 特定端点的 JWT 验证
对于 REST API,在需要的 handlers 中手动验证令牌:
### 在 Routes 中配置
修改 [app/users/api/internal/handler/routes.go](../app/users/api/internal/handler/routes.go)
```go
package handler
import (
"net/http"
"yourmodule/app/users/api/internal/middleware"
"yourmodule/app/users/api/internal/svc"
"yourmodule/app/users/api/internal/handler/user"
)
// RegisterRoutes 注册所有路由
func RegisterRoutes(router *http.ServeMux, svcCtx *svc.ServiceContext) {
// 公开路由
router.HandleFunc("POST /api/v1/auth/login", user.LoginHandler(svcCtx))
router.HandleFunc("POST /api/v1/auth/refresh", user.RefreshTokenHandler(svcCtx))
// 受保护的路由(需要 JWT 验证)
protected := middleware.JwtMiddleware(svcCtx)
router.HandleFunc("GET /api/v1/users/me", protected(user.GetUserInfoHandler(svcCtx)))
router.HandleFunc("POST /api/v1/users/logout", protected(user.LogoutHandler(svcCtx)))
router.HandleFunc("PUT /api/v1/users/me", protected(user.UpdateUserInfoHandler(svcCtx)))
}
```
### 创建 JWT 中间件
创建 [app/users/api/internal/middleware/jwt.go](../../../app/users/api/internal/middleware/jwt.go)
```go
package middleware
import (
"context"
"net/http"
"strings"
"yourmodule/app/users/api/internal/svc"
)
// JwtMiddleware 为 HTTP 处理器添加 JWT 验证
func JwtMiddleware(svcCtx *svc.ServiceContext) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从 Authorization 头提取令牌
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return
}
// 期望格式: "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
return
}
token := parts[1]
// 验证令牌
claims, err := svcCtx.JwtManager.Valid(r.Context(), token)
if err != nil {
// 尝试刷新
newToken, refreshErr := svcCtx.JwtManager.Renew(r.Context(), token)
if refreshErr != nil {
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// 在响应头返回新令牌
w.Header().Set("X-New-Token", newToken)
// 重新验证新令牌
claims, err = svcCtx.JwtManager.Valid(r.Context(), newToken)
if err != nil {
http.Error(w, "Token refresh failed", http.StatusUnauthorized)
return
}
}
// 将声明附加到上下文
newCtx := context.WithValue(r.Context(), "claims", claims)
next.ServeHTTP(w, r.WithContext(newCtx))
})
}
}
```
## 7. 错误处理最佳实践
```go
package logic
import (
"errors"
"log"
"yourmodule/app/users/rpc/internal/utils"
)
// HandleJwtError 处理 JWT 相关错误
func HandleJwtError(err error) error {
if errors.Is(err, utils.ErrTokenExpired) {
log.Println("Token has expired, user needs to refresh")
return errors.New("token expired - use refresh endpoint")
}
if errors.Is(err, utils.ErrTokenInvalid) {
log.Println("Token is invalid or malformed")
return errors.New("invalid token")
}
if errors.Is(err, utils.ErrTokenNotFound) {
log.Println("Token not found in Redis (revoked or expired)")
return errors.New("token revoked or expired")
}
return err
}
```
## 8. 测试 JWT 集成
### 单元测试示例
```go
package interceptor
import (
"context"
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func TestJwtUnaryInterceptor_ValidToken(t *testing.T) {
// 1. 创建有效的令牌
token, err := svcCtx.JwtManager.New(context.Background(), "user123", "user@example.com", "John")
if err != nil {
t.Fatalf("Failed to create token: %v", err)
}
// 2. 创建包含令牌的上下文
md := metadata.Pairs("authorization", token)
ctx := metadata.NewIncomingContext(context.Background(), md)
// 3. 调用拦截器
_, err = JwtUnaryInterceptor(svcCtx)(ctx, nil, nil, func(ctx context.Context, req interface{}) (interface{}, error) {
return "success", nil
})
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func TestJwtUnaryInterceptor_ExpiredToken(t *testing.T) {
// 1. 创建过期的令牌或使用无效令牌
token := "invalid.token.here"
// 2. 创建包含令牌的上下文
md := metadata.Pairs("authorization", token)
ctx := metadata.NewIncomingContext(context.Background(), md)
// 3. 调用拦截器
_, err := JwtUnaryInterceptor(svcCtx)(ctx, nil, nil, func(ctx context.Context, req interface{}) (interface{}, error) {
return "success", nil
})
// 4. 验证错误
st, ok := status.FromError(err)
if !ok || st.Code() != codes.Unauthenticated {
t.Errorf("Expected Unauthenticated error, got: %v", err)
}
}
```
## 9. 生产部署清单
在将 JWT 集成部署到生产环境前:
- [ ] 所有令牌端点都进行了压力测试
- [ ] 令牌刷新逻辑已验证
- [ ] 错误处理覆盖了所有 JWT 失败情况
- [ ] 审计日志记录了所有认证尝试
- [ ] 密钥轮换计划已确定
- [ ] 监控和告警已配置
- [ ] 灾难恢复流程已文档化
- [ ] 所有依赖于 JWT 的服务都已更新
## 相关文件
- [app/users/rpc/internal/utils/jwt.go](../app/users/rpc/internal/utils/jwt.go) - JWT Manager 实现
- [app/users/rpc/internal/config/config.go](../app/users/rpc/internal/config/config.go) - JWT 配置
- [app/users/rpc/internal/svc/serviceContext.go](../app/users/rpc/internal/svc/serviceContext.go) - 依赖注入
- [deploy/k8s/secrets/jwt-secret.yaml](./jwt-secret.yaml) - Secret 和 RBAC
- [deploy/k8s/secrets/DEPLOYMENT.md](./DEPLOYMENT.md) - 部署指南
File diff suppressed because it is too large Load Diff
-108
View File
@@ -1,108 +0,0 @@
# Envoy Gateway Configuration
This document explains how the Envoy unified ingress gateway is configured and how to modify it.
## Files
- deploy/k8s/envoy/envoy.yaml: ConfigMap + Deployment + Service for Envoy
## Current Behavior
- Envoy listens on port 8080 in the Pod and exposes port 80 via a ClusterIP Service.
- Route `/api/users` to `user-api-svc:8888`.
- Route `/api/email` to `email-api-svc:8888`.
- Route `/healthz` returns `200 ok` directly from gateway.
- Unknown routes return `404` from gateway.
## Routing
In envoy.yaml, routes are defined under:
static_resources -> listeners -> http_connection_manager -> route_config -> virtual_hosts
The current routing rules are:
- `prefix: /api/users` -> `cluster: user_api_cluster`
- `prefix: /api/email` -> `cluster: email_api_cluster`
- `path: /healthz` -> direct response `200`
- `prefix: /` -> direct response `404`
To add a new HTTP service, add a new route above the default route and define a new cluster.
Example: route `/api/order` to `order-api-svc:8899`
1) Add a route match:
- match:
prefix: "/api/order"
route:
cluster: order_api_cluster
1) Add a cluster:
- name: order_api_cluster
connect_timeout: 2s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: order_api_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: order-api-svc.juwan.svc.cluster.local
port_value: 8899
## CSRF Protection (Double Cookie)
Envoy uses a Lua filter for double-cookie CSRF validation:
- Safe methods (GET/HEAD/OPTIONS):
- If missing, Envoy auto-issues two cookies:
- `csrf_token`
- `csrf_guard`
- Unsafe methods (POST/PUT/PATCH/DELETE, etc):
- Requires BOTH headers:
- `X-CSRF-Token`
- `X-CSRF-Guard`
- Requires BOTH cookies:
- `csrf_token`
- `csrf_guard`
- Header values must exactly match cookie values, otherwise Envoy returns `403`.
If you want different cookie or header names, update these constants in Lua:
- `TOKEN_COOKIE`
- `GUARD_COOKIE`
- `TOKEN_HEADER`
- `GUARD_HEADER`
To relax or tighten rules, edit the functions:
- is_safe(method)
- envoy_on_request(request_handle)
## Cookie Attributes
Current Set-Cookie:
- `csrf_token=<value>; Path=/; SameSite=Strict`
- `csrf_guard=<value>; Path=/; SameSite=Strict`
## Deployment
Apply or update:
kubectl apply -f deploy/k8s/envoy/envoy.yaml
## Common Changes
- Change listening port:
- Update listener port_value and Service targetPort/port.
- Change service namespace:
- Update cluster DNS addresses (e.g. `service.ns.svc.cluster.local`).
- Add more services:
- Add route + add cluster, as shown above.
- Update CSRF policy:
- Edit Lua validation logic in `envoy.filters.http.lua`.
-385
View File
@@ -1,385 +0,0 @@
# Kubernetes 部署问题排查与解决记录
**日期**: 2026年2月23日
**问题**: user-rpc 和 Redis 部署失败
**状态**: 已诊断,解决中
---
## 📋 问题描述
执行 `kubectl apply -f test.yaml` 后,资源虽然创建成功,但实际的应用 pods 并未正常运行:
```
kubectl apply -f ..\test.yaml
✓ deployment.apps/user-rpc created
✓ service/user-rpc-svc created
✓ horizontalpodautoscaler.autoscaling/user-rpc-hpa-c created
✓ horizontalpodautoscaler.autoscaling/user-rpc-hpa-m created
✓ redisreplication.redis.redis.opstreelabs.in/user-redis created
✓ redissentinel.redis.redis.opstreelabs.in/user-redis-sentinel created
✓ cluster.postgresql.cnpg.io/user-db created
```
但执行 `kubectl get all` 后,发现:
- ❌ **user-rpc pods 未创建**Deployment 0/3 replicas ready
- ❌ **Redis pods 未创建**RedisReplication 资源存在但无 pods
- ✅ user-db pods 正常运行(3/3
---
## 🔍 排查过程
### 第一步:检查 Deployment 状态
```bash
kubectl describe deployment user-rpc
```
**发现**
```
Conditions:
Type Status Reason
---- ------ ------
Progressing True NewReplicaSetCreated
Available False MinimumReplicasUnavailable
ReplicaFailure True FailedCreate
```
### 第二步:检查 ReplicaSet 详情
```bash
kubectl describe replicaset user-rpc-6bf77fbcd9
```
**发现关键错误**
```
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedCreate 3m53s replicaset-controller Error creating:
pods "user-rpc-6bf77fbcd9-" is forbidden: error looking up service
account default/find-endpoints: serviceaccount "find-endpoints" not found
```
**问题 #1 诊断完成**:❌ **缺失 ServiceAccount "find-endpoints"**
### 第三步:检查现有 ServiceAccounts
```bash
kubectl get serviceaccount
```
**结果**
```
NAME AGE
cluster-example 4d10h
default 13d
redis-operator 9h
user-db 4m9s
```
确认 `find-endpoints` 不存在。
### 第四步:检查 Secrets
```bash
kubectl get secrets
```
**结果**:默认 secrets 都存在,包括:
- ✅ user-db-app
- ✅ user-redis
- ✅ user-db-ca, user-db-replication, user-db-server
### 第五步:检查 Redis 部署
```bash
kubectl get redisreplication
kubectl get pods | grep redis
```
**发现**
- ✅ RedisReplication 资源存在
- ❌ Redis pods **完全没有被创建**
**问题 #2 诊断**:❌ **Redis Operator 未响应 RedisReplication 资源**
---
## 🔧 第一次修复尝试
### 创建缺失的 ServiceAccount
```bash
kubectl create serviceaccount find-endpoints
```
**结果**:✅ ServiceAccount 创建成功
### 重启 Deployment
```bash
kubectl rollout restart deployment user-rpc
```
**等待 5-10 秒后重新检查**
```bash
kubectl get pods -o wide
```
**新的发现**
```
NAME READY STATUS RESTARTS AGE
user-rpc-66f97fbdcc-ws7rc 0/1 ErrImagePull 0 26s
user-rpc-6bf77fbcd9-njm2z 0/1 ErrImagePull 0 29s
user-rpc-6bf77fbcd9-nwjtw 0/1 ImagePullBackOff 0 29s
user-rpc-6bf77fbcd9-wjrf8 0/1 ErrImagePull 0 29s
```
**好消息**:Pods 现在被创建了!(说明 ServiceAccount 问题已解决)
**新问题**:镜像拉取失败
---
## 🐛 根因分析
### 问题 #1:缺失 ServiceAccount ✅ 已解决
**根本原因**test.yaml 的 Deployment manifest 指定了:
```yaml
spec:
template:
spec:
serviceAccountName: find-endpoints # 这个 ServiceAccount 不存在
```
但没有在 test.yaml 中创建 ServiceAccount 资源。
**解决方案**
```bash
kubectl create serviceaccount find-endpoints
```
或在 test.yaml 中添加:
```yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: find-endpoints
namespace: default
```
---
### 问题 #2:镜像拉取失败 ❌ 需要修复
```bash
kubectl describe pod user-rpc-6bf77fbcd9-njm2z
```
**详细错误日志**
```
Events:
Warning Failed 38s kubelet Failed to pull image
"103.236.53.208:4418/library/user-rpc@sha256:76b27d3eb4d5d44e...":
Error response from daemon: Get "https://103.236.53.208:4418/v2/":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Warning Failed 23s kubelet Failed to pull image
"103.236.53.208:4418/library/user-rpc@sha256:76b27d3eb4d5d44e...":
http: server gave HTTP response to HTTPS client
```
**根本原因分析**
1. **网络连接失败**`context deadline exceeded` - 无法连接到镜像仓库
2. **协议不匹配**`http: server gave HTTP response to HTTPS client` -
- 地址 `103.236.53.208:4418` 应该是 HTTP 而不是 HTTPS
- Docker daemon 尝试用 HTTPS 连接,但服务器使用 HTTP
**可能原因**
- 镜像仓库地址错误或不可访问
- 镜像仓库需要特定的网络配置
- 仓库服务器离线或配置不当
---
### 问题 #3Redis 部署失败 ❌ 需要诊断
**现象**
- RedisReplication 和 RedisSentinel CRD 资源创建成功
- 但没有对应的 Redis pods 被创建
- `kubectl get pods | grep redis` 返回空
**可能原因**
1. **Redis Operator 未正常工作**
- Operator pod 可能存在问题
- Operator 未能监听到新的 RedisReplication 资源
2. **CRD 或 API 版本问题**
- manifest 中使用的 API 版本 `v1beta2` 可能不匹配 Operator 版本
3. **资源限制或权限问题**
- Operator 无权限创建 pods
- 集群资源限制阻止了 pod 创建
---
## ✅ 已执行的修复
| # | 问题 | 修复方法 | 状态 |
|---|------|---------|------|
| 1 | 缺失 ServiceAccount | `kubectl create serviceaccount find-endpoints` | ✅ 完成 |
| 2 | 镜像拉取失败 | 需要更新镜像地址或修复网络 | ⏳ 待处理 |
| 3 | Redis pods 未创建 | 需要诊断 Operator 日志 | ⏳ 待诊断 |
---
## 🚀 下一步解决方案
### 优先级 1:修复 user-rpc 镜像拉取
**选项 A:使用本地/内部镜像**
```yaml
# 修改 test.yaml 中的镜像地址
image: localhost:5000/user-rpc:latest # 本地私有仓库
# 或
image: user-rpc:latest # 本地镜像(如果已通过 docker load 导入)
```
**选项 B:修复仓库地址**
```yaml
# 如果 103.236.53.208:4418 确实是正确仓库
image: http://103.236.53.208:4418/library/user-rpc:latest # 显式使用 HTTP
```
**验证步骤**
```bash
# 检查镜像仓库连接性
curl -v http://103.236.53.208:4418/v2/
```
### 优先级 2:诊断 Redis Operator
```bash
# 查看 Operator 日志
kubectl logs -l app.kubernetes.io/name=redis-operator -f
# 查看 Operator pod
kubectl get pods -A | grep redis-operator
# 查看 RedisReplication 详细信息
kubectl describe redisreplication user-redis
# 检查 Operator 权限(RBAC
kubectl get role,rolebinding,clusterrole,clusterrolebinding | grep redis
```
### 优先级 3:增强 test.yaml
建议在 test.yaml 中添加缺失的资源定义:
```yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: find-endpoints
namespace: default
---
apiVersion: v1
kind: Secret
metadata:
name: registry-credentials
namespace: default
type: kubernetes.io/dockercfg
data:
.dockercfg: <base64-encoded-credentials> # 如果需要私有仓库认证
```
---
## 📊 当前集群状态
### Pods 状态总结
| 应用 | 期望副本 | 实际运行 | 状态 |
|------|---------|---------|------|
| user-db | 3 | 3 | ✅ 正常 |
| user-rpc | 3 | 0 | ❌ 镜像拉取失败 |
| Redis | 3 | 0 | ❌ Operator 未创建 |
| Sentinel | 3 | 0 | ❌ Operator 未创建 |
### Services 状态
```
✅ kubernetes (内置)
✅ user-rpc-svc:9001
✅ user-db-r:5432 (只读副本)
✅ user-db-ro:5432 (只读副本)
✅ user-db-rw:5432 (读写主副本)
```
### HPA 配置
```
✅ user-rpc-hpa-c (CPU 目标: 80%) - 无法工作(pods 未运行)
✅ user-rpc-hpa-m (Memory 目标: 80%) - 无法工作(pods 未运行)
```
---
## 📝 关键命令速查表
```bash
# 查看 Deployment 状态
kubectl describe deployment user-rpc
# 查看 ReplicaSet 错误事件
kubectl describe replicaset user-rpc-6bf77fbcd9
# 查看 Pod 详细错误
kubectl describe pod user-rpc-6bf77fbcd9-njm2z
# 查看 Pod 日志
kubectl logs user-rpc-6bf77fbcd9-njm2z
# 查看所有事件(按时间排序)
kubectl get events --sort-by='.lastTimestamp'
# 查看特定命名空间的所有资源
kubectl get all -n default
# 重新启动 deployment(强制重新创建 pods
kubectl rollout restart deployment user-rpc
# 查看 Operator 日志
kubectl logs -l app.kubernetes.io/name=redis-operator
# 检查 CRD 注册状态
kubectl api-resources | grep redis
```
---
## 🎯 总结
| 问题 | 原因 | 解决状态 |
|------|------|---------|
| **ServiceAccount 缺失** | manifest 中声明但未创建 | ✅ **已解决** |
| **镜像拉取失败** | 仓库地址不可达或协议不匹配 | ⏳ **待处理** |
| **Redis 未部署** | Operator 未响应 CRD | ⏳ **待诊断** |
**建议行动**
1. 确认/修复 user-rpc 镜像地址
2. 诊断 Redis Operator 状态
3. 验证所有依赖的 ServiceAccounts 和 Secrets 是否存在
4. 考虑在 test.yaml 中添加完整的资源定义,避免手工创建
-266
View File
@@ -1,266 +0,0 @@
# Email Consumer Kafka 投递与日志验证实验手册
## 1. 实验目标
验证 `email-task` consumer 是否能正常消费 Kafka 消息,并在日志中打印消费内容。
本实验同时给出两种验证方式:
1. `kubectl logs` 直接查看 Pod 日志
2. Grafana + Loki 查看聚合日志
---
## 2. 实验前提
### 2.1 需要满足的运行状态
```bash
kubectl -n juwan get pods -l app=email-task
kubectl -n kafka get pods
kubectl -n monitoring get pods
```
预期:
- `email-task` 至少 1 个 Pod 为 `Running`
- Kafka 集群有可用 broker(如 `my-cluster-kafka-pool-0`
- `loki/promtail/grafana``Running`(若需要 Loki 验证)
### 2.2 本次实验使用的关键配置
来自 `app/email/mq/etc/email.yaml`
- Broker: `my-cluster-kafka-bootstrap.kafka.svc.cluster.local:9092`
- Topic: `email-task`
- Group: `email-consumer-group`
---
## 3. 实验步骤(详细)
## 步骤 1:确认 Topic 存在
### 目的
避免消息投递到不存在的 Topic,导致消费端无数据。
### 指令
```bash
kubectl -n kafka exec my-cluster-kafka-pool-0 -- \
/opt/kafka/bin/kafka-topics.sh \
--bootstrap-server my-cluster-kafka-bootstrap:9092 \
--list
```
### 预期结果
输出中包含:
- `email-task`
---
## 步骤 2:投递一条最小测试消息(纯文本)
### 目的
先验证链路通路(producer -> kafka -> consumer)是否正常,不引入 JSON 转义复杂度。
### 指令
```bash
kubectl -n kafka exec my-cluster-kafka-pool-0 -- /bin/bash -lc \
"printf 'test-email-message\\n' | \
/opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server my-cluster-kafka-bootstrap:9092 \
--topic email-task"
```
### 预期结果
命令正常返回(通常无额外输出)。
---
## 步骤 3:查看 consumer 日志(kubectl 直查)
### 目的
确认 consumer 实际收到消息并执行日志打印。
### 指令(回看最近日志)
```bash
kubectl -n juwan logs -l app=email-task --tail=120
```
### 指令(实时追踪)
```bash
kubectl -n juwan logs -l app=email-task -f --since=10m
```
### 预期日志示例
```text
Consume get message key: , value: test-email-message
```
说明:
- key 为空是正常的(本次 producer 未设置 key
- value 为投递内容,说明消费链路正常
---
## 步骤 4:投递业务消息(验证码 JSON)
### 目的
模拟真实业务 payload,验证 consumer 对业务消息格式的处理。
### 指令
```bash
kubectl -n kafka exec my-cluster-kafka-pool-0 -- /bin/bash -lc "cat <<'EOF' | \
/opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server my-cluster-kafka-bootstrap:9092 \
--topic email-task
{\"type\":\"verification_code\",\"email\":\"test@example.com\",\"code\":\"123456\",\"scene\":\"login\",\"expired_minutes\":5}
EOF"
```
### 预期结果
- producer 正常返回
- `email-task` 日志可看到包含 JSON 的消费日志
---
## 步骤 5:投递业务消息(活动通知 JSON)
### 目的
验证另一类业务消息(活动通知)通路。
### 指令
```bash
kubectl -n kafka exec my-cluster-kafka-pool-0 -- /bin/bash -lc "cat <<'EOF' | \
/opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server my-cluster-kafka-bootstrap:9092 \
--topic email-task
{\"type\":\"activity_notice\",\"email\":\"test@example.com\",\"title\":\"春季活动\",\"content\":\"满100减20\",\"activity_id\":\"A20260225\"}
EOF"
```
### 预期结果
- producer 正常返回
- consumer 日志出现活动消息内容
---
## 步骤 6:使用 Loki/Grafana 验证(可选)
### 目的
确认日志采集链路(Promtail -> Loki -> Grafana)正常,便于后续线上排查。
### 6.1 打开 Grafana
```bash
kubectl port-forward -n monitoring svc/grafana 3000:3000
```
浏览器:`http://localhost:3000`
### 6.2 在 Explore 中查询
使用 Loki 数据源,输入:
```logql
{job="kubernetes-pods", namespace="juwan", app="email-task"} |= "Consume get message"
```
若没有结果:
1. 把时间范围调大到 `Last 6 hours`/`Last 24 hours`
2. 放宽查询条件:
```logql
{job="kubernetes-pods", namespace="juwan", pod=~"email-task-.*"}
```
---
## 4. 一键复现实验命令(顺序执行)
```bash
# 1) 查看 topic
kubectl -n kafka exec my-cluster-kafka-pool-0 -- /opt/kafka/bin/kafka-topics.sh --bootstrap-server my-cluster-kafka-bootstrap:9092 --list
# 2) 发测试消息
kubectl -n kafka exec my-cluster-kafka-pool-0 -- /bin/bash -lc "printf 'test-email-message\\n' | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server my-cluster-kafka-bootstrap:9092 --topic email-task"
# 3) 看 consumer 日志
kubectl -n juwan logs -l app=email-task --tail=120
```
---
## 5. 常见问题与处理
### 问题 1:发消息命令报引号/EOF错误
现象:`unexpected EOF while looking for matching`
原因:Shell 引号转义不正确。
处理:
- 先用纯文本消息验证链路
- JSON 使用 here-doc`cat <<'EOF'`)方式,避免转义混乱
### 问题 2:发了消息但 consumer 无日志
排查顺序:
1. `email-task` 是否 Running
2. Topic 是否正确(`email-task`
3. consumer group 是否一致(`email-consumer-group`
4. 查看 Pod 实时日志(`-f`
5. 若只看 Loki,请放大时间窗口并放宽标签条件
### 问题 3Loki 查不到但 kubectl logs 能看到
说明业务正常,问题在日志采集查询链路:
- 检查 Promtail target 是否 ready
- 检查 Loki 查询标签/时间范围
- 参考 `docs/loki-log-troubleshooting.md`
---
## 6. 实验结论判定标准
满足以下任一即可判定消费链路可用:
1. `kubectl logs` 出现:`Consume get message ...`
2. Grafana Loki 查询出现对应消费日志
若两者都出现,说明:
- Kafka 投递正常
- Consumer 消费正常
- 日志采集与检索链路正常
---
## 7. 关联文档
- Loki 使用:`docs/loki-usage-guide.md`
- Loki 排错:`docs/loki-log-troubleshooting.md`
- Email 部署排错:`docs/email-task-deployment-troubleshooting.md`
@@ -1,147 +0,0 @@
# Email Task 部署故障排查与修复记录
## 1. 问题现象
部署 `email-task` 时出现调度失败:
```text
Warning FailedScheduling 0/1 nodes are available: 1 Insufficient memory.
no new claims to deallocate, preemption: 0/1 nodes are available:
1 No preemption victims found for incoming pod.
```
表现为:
- `Deployment` 期望副本无法全部就绪
- `Pod` 长时间 `Pending`
---
## 2. 排查思路
按以下顺序排查:
1. **看部署配置是否过高请求**`requests/limits` + `replicas`
2. **看节点可分配资源和已分配资源**(确认是否真的是内存不足)
3. **看滚动策略是否会额外拉起新 Pod**`maxSurge` 可能放大内存压力)
4. **看容器健康检查是否匹配服务类型**(任务型服务不一定监听端口)
---
## 3. 关键排查命令
### 3.1 查看节点可分配资源
```powershell
kubectl get nodes -o custom-columns=NAME:.metadata.name,ALLOCATABLE_CPU:.status.allocatable.cpu,ALLOCATABLE_MEM:.status.allocatable.memory
```
### 3.2 查看部署与 Pod 状态
```powershell
kubectl -n juwan get deploy email-task -o wide
kubectl -n juwan get pods -l app=email-task -o wide
kubectl -n juwan describe pod -l app=email-task
```
### 3.3 查看节点资源分配占比
```powershell
kubectl describe node minikube
```
关注输出中的 `Allocated resources`
- `memory requests` 已接近节点上限(本次约 97%
### 3.4 查看部署策略与探针配置
```powershell
kubectl -n juwan get deploy email-task -o yaml
kubectl -n juwan logs deploy/email-task --tail=120
```
---
## 4. 根因分析
本次是**组合问题**
1. **内存请求过高 + 副本过多**
- 原始配置:`replicas=3`
- 每个 Pod 请求 `memory=512Mi`
- 单节点场景下,叠加现有业务后无法继续调度
2. **滚动更新默认 `maxSurge=25%`**
- 更新时可能额外起新 Pod,进一步触发内存不足
3. **探针不匹配服务行为**
- 原配置为 `tcpSocket:8080` 探针
- 实际 `email-task` 是任务型服务,日志显示启动后并未提供该端口服务
- 导致 `Readiness/Liveness` 持续失败
---
## 5. 修复方案
仅修改文件:
- `deploy/k8s/service/email/email.yaml`
### 5.1 降低资源请求与副本基线
- `replicas: 3 -> 1`
- `requests.cpu: 500m -> 100m`
- `requests.memory: 512Mi -> 128Mi`
- `limits.cpu: 1000m -> 500m`
- `limits.memory: 1024Mi -> 512Mi`
### 5.2 调整 HPA 基线与上限
- 两个 HPACPU / Memory)统一:
- `minReplicas: 3 -> 1`
- `maxReplicas: 10 -> 3`
### 5.3 调整滚动发布策略
- `strategy.rollingUpdate.maxSurge: 0`
- `strategy.rollingUpdate.maxUnavailable: 1`
目的:避免滚动期间额外拉起 Pod 造成瞬时内存不足。
### 5.4 移除不适配的 8080 TCP 探针
移除:
- `readinessProbe.tcpSocket:8080`
- `livenessProbe.tcpSocket:8080`
---
## 6. 修复执行命令
```powershell
kubectl apply -f deploy/k8s/service/email/email.yaml
kubectl -n juwan rollout restart deploy/email-task
kubectl -n juwan rollout status deploy/email-task --timeout=180s
kubectl -n juwan get pods -l app=email-task -o wide
kubectl -n juwan describe pods -l app=email-task | Select-String -Pattern 'FailedScheduling|Unhealthy|Warning|Events|Node:'
```
---
## 7. 修复结果
- `Deployment` 滚动成功
- 新 Pod 成功调度并 `Running`
- 无新的 `FailedScheduling``Unhealthy` 事件
---
## 8. 后续建议
1. 若要恢复多副本,先按节点容量逐步上调(建议先 2 副本并观测)。
2. 为任务型服务设计更合适的健康检查方式:
- 可考虑 `exec` 探针或业务自检端点。
3. 在单节点开发环境中统一降低默认 `requests`,防止多个服务叠加后调度失败。
4. 如需高可用,建议扩容节点而不是仅依赖压缩资源。
File diff suppressed because it is too large Load Diff
-743
View File
@@ -1,743 +0,0 @@
# Redis Kubernetes Service 详细解析
**问题:** 为什么 Redis 有 8 个 Service,但应用配置中只使用 `user-redis-sentinel-sentinel.juwan.svc.cluster.local:26379`
**日期:** 2026年2月22日
---
## 📋 目录
1. [Service 概览](#service-概览)
2. [Kubernetes Service 基础](#kubernetes-service-基础)
3. [8 个 Service 的详细说明](#8-个-service-的详细说明)
4. [为什么使用哪个 Service](#为什么使用哪个-service)
5. [Service 创建原理](#service-创建原理)
6. [网络流量路由](#网络流量路由)
7. [故障排查](#故障排查)
---
## 📊 Service 概览
### 当前 Redis 的 8 个 Service
```bash
$ kubectl get svc -n juwan | grep redis
NAME TYPE CLUSTER-IP PORTS
user-redis ClusterIP 10.103.91.84 6379/TCP,9121/TCP 33m
user-redis-additional ClusterIP 10.107.228.48 6379/TCP 33m
user-redis-headless ClusterIP None 6379/TCP 33m
user-redis-master ClusterIP 10.97.120.76 6379/TCP 33m
user-redis-replica ClusterIP 10.100.213.103 6379/TCP 33m
user-redis-sentinel-sentinel ClusterIP 10.105.28.231 26379/TCP 32m
user-redis-sentinel-sentinel-additional ClusterIP 10.97.111.42 26379/TCP 32m
user-redis-sentinel-sentinel-headless ClusterIP None 26379/TCP 32m
```
### 按功能分类
| 分类 | Service 名称 | 作用 |
|-----|-------------|------|
| **Redis 数据层** | user-redis | 通用入口 |
| | user-redis-additional | 备用入口 |
| | user-redis-master | 主节点专用 |
| | user-redis-replica | 从节点专用 |
| | user-redis-headless | Pod 间通信 |
| **Sentinel 监控层** | user-redis-sentinel-sentinel | Sentinel 入口 ⭐ |
| | user-redis-sentinel-sentinel-additional | 备用入口 |
| | user-redis-sentinel-sentinel-headless | Sentinel 间通信 |
---
## 🔷 Kubernetes Service 基础
### Service 的作用
**Kubernetes 中的 Service 是什么?**
```
┌─────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
│ Service (虚拟 IP + DNS) │
│ ↓ │
│ Endpoints (实际 Pod IP 列表) │
│ ├─ 10.244.0.10:6379 (Pod 1) │
│ ├─ 10.244.1.20:6379 (Pod 2) │
│ └─ 10.244.2.30:6379 (Pod 3) │
│ │
│ 客户端 ──→ Service IP (稳定) ──→ Pod IP (变化) │
└─────────────────────────────────────────────────┘
```
### Service 的三种类型
| 类型 | CLUSTER-IP | 用途 | 示例 |
|-----|-----------|------|------|
| **ClusterIP** | ✅ 有 | 集群内访问 | 10.103.91.84 |
| **ClusterIP<br/>(Headless)** | ❌ None | Pod 间直接通信 | None |
| **NodePort** | ✅ 有 | 集群外访问 | 10.103.91.84 |
---
## 🔍 8 个 Service 的详细说明
### 第一组:Redis 数据层 Service(端口 6379
#### 1️⃣ user-redisClusterIP
**基本信息:**
```yaml
名称: user-redis
类型: ClusterIP (有负载均衡)
Cluster IP: 10.103.91.84
端口: 6379/TCP, 9121/TCP
DNS: user-redis.juwan.svc.cluster.local
```
**Endpoints 信息:**
```bash
$ kubectl get endpoints user-redis -n juwan
NAME ENDPOINTS
user-redis 10.244.0.10:6379,10.244.1.20:6379,10.244.2.30:6379
```
**负载均衡机制:**
```
客户端请求 ──→ Service IP (10.103.91.84)
kube-proxy (iptables/ipvs)
随机选择一个 Pod
├─ 10.244.0.10 (redis-0)
├─ 10.244.1.20 (redis-1) ← 可能
└─ 10.244.2.30 (redis-2)
```
**特点:**
- ✅ 对所有 Pod 轮询负载均衡
- ✅ 包含 Redis 数据服务(6379)和 Exporter9121
- ⚠️ 可能把写请求轮询到从节点导致失败
**适用场景:**
- 监控抓取(Prometheus 从 9121 端口抓指标)
- 不关心读写分离的简单查询
**为什么有 2 个端口?**
```
6379: Redis 数据服务
9121: Prometheus Exporter 监控端口
└─ 暴露 Redis 性能指标给 Prometheus
(redis_up, redis_memory_used, etc.)
```
**不用这个的原因:**
```
❌ 如果直接使用 user-redis 进行读写:
├─ 写请求可能被路由到从节点 (error)
├─ 无法进行故障自动转移
└─ 依赖于手动更新配置
```
---
#### 2️⃣ user-redis-additionalClusterIP
**基本信息:**
```yaml
名称: user-redis-additional
类型: ClusterIP (有负载均衡)
Cluster IP: 10.107.228.48
端口: 6379/TCP
Endpoints: 同 user-redis
```
**作用:**
- 功能完全同 `user-redis`
- 提供额外的访问入口
- 用于多租户/网络隔离场景
**为什么有这个?**
```
场景:某些网络策略可能只允许访问特定 Service
└─ 额外的 Service 提供备用入口
```
**不常用的原因:**
- 大多数场景用 `user-redis` 就足够
- `user-redis-additional` 是备用
---
#### 3️⃣ user-redis-headlessClusterIP: None
**基本信息:**
```yaml
名称: user-redis-headless
类型: ClusterIP (Headless Service)
Cluster IP: None ← 关键:无虚拟 IP
端口: 6379/TCP
DNS: user-redis-headless.juwan.svc.cluster.local
```
**特殊之处:无虚拟 IP**
```bash
# 正常 Service 查询返回虚拟 IP
$ nslookup user-redis.juwan.svc.cluster.local
Name: user-redis.juwan.svc.cluster.local
Address: 10.103.91.84 ← 虚拟 IP
# Headless Service 查询返回所有 Pod IP
$ nslookup user-redis-headless.juwan.svc.cluster.local
Name: user-redis-headless.juwan.svc.cluster.local
Address: 10.244.0.10 ← Pod 1 实际 IP
Address: 10.244.1.20 ← Pod 2 实际 IP
Address: 10.244.2.30 ← Pod 3 实际 IP
```
**使用场景:**
```
┌─────────────────────────────────────────────────┐
│ StatefulSet (Redis Cluster/Replication) │
│ │
│ redis-0 (主) redis-1 (从) redis-2 (从) │
│ ↓ ↓ ↓ │
│ 10.244.0.10 10.244.1.20 10.244.2.30 │
│ ↑ │
│ 需要直接连接到特定 Pod: │
│ redis-0.user-redis-headless (连接主节点) │
│ redis-1.user-redis-headless (连接从节点) │
└─────────────────────────────────────────────────┘
```
**谁在使用?**
- Redis 主从复制:从节点需要连接到已知的主节点
- Sentinel 监控:需要直接访问特定 Redis 实例
- Redis Operator 内部使用
**为什么应用不用这个?**
```
❌ Pod DNS 只能在 Pod 内使用
└─ 外部应用不知道 Pod 的具体 DNS 名称
✅ 用虚拟 Service IP 的优势
└─ 无需关心底层 Pod 变化
```
---
#### 4️⃣ user-redis-masterClusterIP
**基本信息:**
```yaml
名称: user-redis-master
类型: ClusterIP
Cluster IP: 10.97.120.76
端口: 6379/TCP
Endpoints: 10.244.0.10:6379 (只有 1 个 Pod)
DNS: user-redis-master.juwan.svc.cluster.local
```
**特点:只指向主节点**
```bash
$ kubectl get endpoints user-redis-master -n juwan
NAME ENDPOINTS
user-redis-master 10.244.0.10:6379 ← 仅主节点
```
**对比所有 Endpoints**
```
user-redis-master: 10.244.0.10 (主)
user-redis-replica: 10.244.1.20, 10.244.2.30 (从)
user-redis: 所有 Pod
```
**为什么分开?**
```
┌─────────────────────────────────────────┐
│ Redis 主从架构 │
│ │
│ Redis Master (10.244.0.10) │
│ ├─ 处理所有写操作 │
│ └─ 赋值数据给 Slave │
│ │
│ Redis Slave 1 (10.244.1.20) │
│ └─ 处理只读操作 │
│ │
│ Redis Slave 2 (10.244.2.30) │
│ └─ 处理只读操作 │
└─────────────────────────────────────────┘
请求分类:
┌───────────────────────┐
│ SET key value │ ──→ user-redis-master (10.97.120.76)
│ HSET user:1 name john │
└───────────────────────┘
┌───────────────────────┐
│ GET key │ ──→ user-redis-replica (10.100.213.103)
│ HGET user:1 name │
└───────────────────────┘
```
**适用场景:**
- ✅ 读写分离架构
- ✅ 优化读性能(从节点处理读)
- ✅ 减轻主节点负担
**为什么应用通常不直接用?**
```
❌ 需要在应用层面区分读写操作
├─ 写操作 → user-redis-master
├─ 只读操作 → user-redis-replica
└─ 代码复杂度高
✅ Sentinel 模式自动处理
└─ 应用无需关心主从区别
```
---
#### 5️⃣ user-redis-replicaClusterIP
**基本信息:**
```yaml
名称: user-redis-replica
类型: ClusterIP
Cluster IP: 10.100.213.103
端口: 6379/TCP
Endpoints: 10.244.1.20:6379, 10.244.2.30:6379 (两个从节点)
DNS: user-redis-replica.juwan.svc.cluster.local
```
**特点:只指向从节点,支持负载均衡**
```bash
$ kubectl get endpoints user-redis-replica -n juwan
NAME ENDPOINTS
user-redis-replica 10.244.1.20:6379, 10.244.2.30:6379
```
**读流量分散:**
```
应用发送 GET 请求
user-redis-replica (10.100.213.103)
随机选择一个从节点
├─ 10.244.1.20 (redis-1) ← 可能
└─ 10.244.2.30 (redis-2) ← 可能
```
**适用场景:**
- 除了 Sentinel 模式外的读优化
- 需要手动管理读写分离
---
### 第二组:Sentinel 监控层 Service(端口 26379
#### 6️⃣ user-redis-sentinel-sentinelClusterIP)⭐⭐⭐
**基本信息:**
```yaml
名称: user-redis-sentinel-sentinel
类型: ClusterIP
Cluster IP: 10.105.28.231
端口: 26379/TCP
Endpoints: 10.244.0.50:26379, 10.244.1.70:26379, 10.244.2.90:26379
(3 个 Sentinel 实例)
DNS: user-redis-sentinel-sentinel.juwan.svc.cluster.local
```
**为什么应用使用这个?**
```
应用程序配置:
┌──────────────────────────────────────────────┐
│ Redis: │
│ Host: user-redis-sentinel-sentinel │
│ Port: 26379 │
│ Type: sentinel │
│ MasterName: mymaster │
└──────────────────────────────────────────────┘
连接流程:
┌─────────────────────────────────────────────┐
│ 应用程序 │
└────────────────────┬────────────────────────┘
┌─────────────────────────────────────────────┐
│ user-redis-sentinel-sentinel (26379) │
│ ├─ Sentinel 1: 10.244.0.50:26379 │
│ ├─ Sentinel 2: 10.244.1.70:26379 │
│ └─ Sentinel 3: 10.244.2.90:26379 │
└────────────────────┬────────────────────────┘
应用询问: "mymaster 在哪?"
Sentinel 回答: "在 10.244.0.10:6379"
┌─────────────────────────────────────────────┐
│ Redis Master: 10.244.0.10:6379 │
│ (应用直接连接进行读写) │
└─────────────────────────────────────────────┘
故障转移过程:
Master 故障 → Sentinel 检测 → 提升新主节点
→ 应用下次查询时 → 获得新主节点 IP
→ 自动连接新主节点
```
**为什么这是最佳选择?**
1. **自动故障转移**
```
主节点宕机 (✗) → Sentinel 自动选举新主 → 应用自动连接
```
2. **高可用**
```
Sentinel 集群(3 个) → 任意 1-2 个故障仍可用
```
3. **应用无感知**
```
应用只需配置 MasterName: mymaster
无需关心主从地址变化
```
4. **标准做法**
```
✅ 业界公认的 Redis 高可用方案
✅ 最小化应用改动
✅ 自动化程度最高
```
**为什么不用其他 Service**
```
❌ user-redis-master/user-redis-replica
└─ 需要应用层区分读写,主从切换需要重启应用
❌ user-redis/user-redis-additional
└─ 没有故障转移能力,故障时应用会报错
✅ user-redis-sentinel-sentinel
└─ 自动发现新主节点,无需重启应用
```
---
#### 7️⃣ user-redis-sentinel-sentinel-additionalClusterIP
**说明:** 功能同 `user-redis-sentinel-sentinel`,备用入口
---
#### 8️⃣ user-redis-sentinel-sentinel-headlessClusterIP: None
**说明:** 供 Sentinel 内部通信和选举使用
---
## 🎯 为什么使用哪个 Service
### 应用配置选择
#### ⭐⭐⭐ Sentinel 模式(生产推荐)
```yaml
# 应用配置
Redis:
Host: user-redis-sentinel-sentinel.juwan.svc.cluster.local:26379
Type: sentinel
MasterName: mymaster
Pass: ${REDIS_PASSWORD}
```
**优势:**
- ✅ 自动故障转移(RTO < 30 秒)
- ✅ 应用无需重启
- ✅ 自动发现新主节点
- ✅ 生产标准做法
---
#### ⭐⭐ 主从分离模式(可选)
```yaml
# 应用配置(需要两个 host
Redis:
Master:
Host: user-redis-master.juwan.svc.cluster.local:6379
Slave:
Host: user-redis-replica.juwan.svc.cluster.local:6379
```
**适用场景:**
- 读写分离显著
- 对读性能有极高要求
**缺点:**
- 主从故障需手动切换
- 应用层复杂度高
---
#### ❌ 不推荐的做法
```yaml
# ❌ 直接连接单个节点
Redis:
Host: user-redis-0.user-redis-headless.juwan.svc.cluster.local:6379
# 问题:Pod 重启 IP 变化,需要更新配置
# ❌ 连接通用 Service(无故障转移)
Redis:
Host: user-redis.juwan.svc.cluster.local:6379
# 问题:无法自动转移,故障时应用报错
# ❌ 硬编码 Pod IP
Redis:
Host: 10.244.0.10:6379
# 问题:Pod 重启 IP 变化,应用立即不可用
```
---
## 🔌 Service 创建原理
### 为什么会自动创建这么多 Service?
**由 Redis Operator 自动创建:**
```go
// Redis Operator 逻辑(伪代码)
func CreateServicesForRedis(redis *RedisReplication) {
// 数据层 Service
CreateService("user-redis", AllRedisNodes)
CreateService("user-redis-additional", AllRedisNodes)
CreateService("user-redis-master", [MasterNode])
CreateService("user-redis-replica", [SlaveNodes])
CreateHeadlessService("user-redis-headless", AllRedisNodes)
// 监控层 Service
CreateService("user-redis-sentinel-sentinel", AllSentinelNodes)
CreateService("user-redis-sentinel-sentinel-additional", AllSentinelNodes)
CreateHeadlessService("user-redis-sentinel-sentinel-headless", AllSentinelNodes)
}
```
**为什么这样设计?**
| Service | 原因 |
|---------|------|
| 多个 ClusterIP | 不同场景需要不同的 Endpoints 配置 |
| 包含 additional | 网络隔离/多租户支持 |
| 包含 headless | StatefulSet 需要 Pod 间直接通信 |
**类比:**
```
Redis Operator 就像一个完整的产品
└─ 提供多种方式使用 Redis
├─ 简单: user-redis
├─ 高级: user-redis-master/replica
├─ HA: user-redis-sentinel-sentinel
└─ 内部: headless services
```
---
## 🌐 网络流量路由
### 查询 Service 背后的 Pod
**查看 Service Endpoints**
```bash
# 查看 user-redis 关联的 Pod
$ kubectl get endpoints user-redis -n juwan
NAME ENDPOINTS
user-redis 10.244.0.10:6379,10.244.1.20:6379,10.244.2.30:6379
# 查看 user-redis-master 关联的 Pod
$ kubectl get endpoints user-redis-master -n juwan
NAME ENDPOINTS
user-redis-master 10.244.0.10:6379
# 查看 user-redis-replica 关联的 Pod
$ kubectl get endpoints user-redis-replica -n juwan
NAME ENDPOINTS
user-redis-replica 10.244.1.20:6379,10.244.2.30:6379
```
**Pod 和 Service 的映射关系:**
```
Pods (实际运行的实例) Services (虚拟 IP)
└─ redis-0 (主) └─ user-redis (所有)
├─ 10.244.0.10 ├─ 10.103.91.84
└─ :6379
└─ user-redis-master (仅主)
└─ redis-1 (从) ├─ 10.97.120.76
├─ 10.244.1.20
└─ :6379
└─ user-redis-replica (仅从)
└─ redis-2 (从) ├─ 10.100.213.103
├─ 10.244.2.30
└─ :6379
```
**DNS 解析过程:**
```
应用 DNS 查询
└─ user-redis-master.juwan.svc.cluster.local
CoreDNS (Kubernetes DNS)
└─ 查询并返回 Service IP:
├─ 10.97.120.76 (user-redis-master)
├─ 或 10.100.213.103 (user-redis-replica)
├─ 或 10.103.91.84 (user-redis)
└─ 或 Sentinel 的 IP
```
**Sentinel 模式的特殊之处:**
```
应用查询 Sentinel
└─ user-redis-sentinel-sentinel.juwan.svc.cluster.local:26379
Sentinel Service (负载均衡到 3 个 Sentinel 节点)
Sentinel 节点 (任选一个)
应用询问: "mymaster 主节点 IP 是什么?"
Sentinel 回答: "10.244.0.10:6379"
应用直接连接 Redis Master: 10.244.0.10:6379
```
---
## 🔧 故障排查
### 问题 1:为什么应用连接失败?
**检查步骤:**
```bash
# 1. 验证 Service 存在
kubectl get svc user-redis-sentinel-sentinel -n juwan
# 2. 验证 Endpoints 不为空
kubectl get endpoints user-redis-sentinel-sentinel -n juwan
# 3. 测试 DNS 解析
kubectl run -it --rm nettest --image=busybox --restart=Never -n juwan -- \
nslookup user-redis-sentinel-sentinel.juwan.svc.cluster.local
# 4. 测试连接性
kubectl run -it --rm nettest --image=busybox --restart=Never -n juwan -- \
nc -zv user-redis-sentinel-sentinel.juwan.svc.cluster.local 26379
# 5. 查看应用日志
kubectl logs -f user-rpc-xxx -n juwan
```
### 问题 2:为什么看不到某个 Service?
```bash
# 确保在正确的命名空间
kubectl get svc -n juwan | grep redis
# 如果 Redis Operator 有问题,Service 可能不会创建
# 查看 Operator 日志
kubectl logs -n default deployment/redis-operator
```
### 问题 3Service IP 经常变化?
```bash
# Service IP 是稳定的(除非被删除和重建)
# 如果频繁变化,说明 Service 被频繁重建
# 检查 Service 创建事件
kubectl describe svc user-redis-sentinel-sentinel -n juwan
# 检查 Operator 是否有异常
kubectl describe redissentinel user-redis-sentinel -n juwan
```
---
## 📚 总结
### 快速理解
| Service | 用途 | 应用是否使用 |
|---------|------|-----------|
| **user-redis-sentinel-sentinel** | ⭐ Sentinel 高可用 | ✅ **生产推荐** |
| user-redis-master | 直连主节点 | ⚠️ 需要读写分离 |
| user-redis-replica | 直连从节点 | ⚠️ 需要读写分离 |
| user-redis | 通用入口 | ❌ 不推荐(无 HA) |
| headless services | 内部通信 | ❌ 应用不用 |
### 为什么有这么多 Service
**答案:** 为了提供灵活的使用方式
```
Redis Operator 的设计理念:
┌─────────────────────────────────────────┐
│ 提供完整的 Redis 高可用解决方案 │
│ │
│ ├─ 简单使用场景 │
│ │ └─ user-redis (所有节点) │
│ │ │
│ ├─ 高级使用场景 │
│ │ ├─ user-redis-master (写) │
│ │ └─ user-redis-replica (读) │
│ │ │
│ ├─ 生产场景 (推荐) │
│ │ └─ user-redis-sentinel-sentinel │
│ │ │
│ └─ 内部通信 │
│ └─ headless services │
└─────────────────────────────────────────┘
```
### 应用该用哪个?
**一句话:使用 `user-redis-sentinel-sentinel:26379` + Sentinel 模式**
```yaml
# 这是最佳实践
Redis:
Host: user-redis-sentinel-sentinel.juwan.svc.cluster.local:26379
Type: sentinel
MasterName: mymaster
```
**为什么?**
- ✅ 自动故障转移
- ✅ 应用无需重启
- ✅ 无需手工干预
- ✅ 行业标准
---
**文档版本:** 1.0
**创建日期:** 2026年2月22日
**维护者:** DevOps Team
-216
View File
@@ -1,216 +0,0 @@
# Loki 无日志排查与修复手册
## 背景
现象:Grafana Explore 使用 Loki 数据源查询 `{job="kubernetes-pods"}` 时无结果,页面提示 `No logs found`
影响:日志链路不可用,无法按服务排查线上问题。
链路目标:
- Promtail 采集 Kubernetes Pod 日志
- Loki 存储与检索日志
- Grafana 查询展示日志
---
## 一、排查思路
本次按“组件健康 -> 采集发现 -> 文件可读 -> 入库验证 -> 查询验证”的顺序排查。
### 1) 确认监控组件健康
先确认 Promtail/Loki/Grafana 是否都在 Running,避免在异常状态下排查配置。
### 2) 判断是“没采到”还是“没查到”
通过 Promtail `/targets``/service-discovery` 页面确认是否存在 active target。
- 若 `0/0``0/1 unready`,说明采集端有问题
- 若 target 正常但 Loki 无数据,再看推送或查询标签
### 3) 检查 Promtail 能否访问节点日志文件
重点确认:
- `/var/log/pods` 是否可见
- 采集路径是否匹配
- 是否存在大量 `stat ... no such file or directory`
### 4) 直连 Loki API 验证是否入库
绕过 Grafana,直接访问 Loki API。
- 若 API 无数据,问题在 Promtail 采集/推送链路
- 若 API 有数据,问题在 Grafana 查询条件、时间范围或标签
---
## 二、根因分析
本次属于“Promtail 侧采集链路不完整”,主要问题如下:
1. Kubernetes SD 目标未生效(Promtail targets 显示 `kubernetes-pods (0/0)`)。
2. 即使加入静态采集,目标一度 `0/1 unready`
3. `/var/log/pods` 下日志多为符号链接,真实目标在 `/var/lib/docker/containers`
4. Promtail 容器未挂载 `/var/lib/docker/containers`,导致大量 `stat ... no such file or directory`
5. 标签维度不足,不便于按业务服务名筛选日志。
---
## 三、修复思路
### 1) 强化 Promtail 权限与发现能力
`deploy/k8s/monitoring/promtail.yaml` 中:
- 补充 RBAC 资源权限:
- `nodes`
- `pods`
- `pods/log`
- `services`
- `endpoints`
- `namespaces`
### 2) 增加静态采集兜底
`scrape_configs` 中新增 `kubernetes-pods-static`,路径:
- `/var/log/pods/*/*/*.log`
用于在 Kubernetes SD 临时失效时仍能采集日志。
### 3) 修复宿主机日志访问链路
Promtail DaemonSet 增加:
- `securityContext.runAsUser: 0`
- `securityContext.runAsGroup: 0`
- 挂载 `hostPath: /var/lib/docker/containers`
并挂载到容器内同路径只读。
### 4) 完善标签体系,支持按服务筛选
新增/保留标签:
- `namespace`
- `pod`
- `container`
- `app`
静态采集通过 `pipeline_stages``filename` 解析标签,并从 `pod` 生成 `app`(去除滚动后缀)。
---
## 四、关键变更文件
- `deploy/k8s/monitoring/promtail.yaml`
本次 Loki 主配置与 Grafana 数据源无需改动,核心修复集中在 Promtail 采集侧。
---
## 五、排查与修复命令清单
> 以下命令均在项目根目录执行。
### 1) 组件状态检查
```powershell
kubectl get pods -n monitoring -o wide
kubectl get svc -n monitoring
kubectl logs -n monitoring -l app=promtail --tail=120
kubectl logs -n monitoring -l app=loki --tail=80
```
### 2) Promtail 文件系统与配置检查
```powershell
$pod=(kubectl get pod -n monitoring -l app=promtail -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n monitoring $pod -- sh -c "ls -ld /var/log /var/log/pods /var/log/containers"
kubectl exec -n monitoring $pod -- sh -c "find /var/log/pods -name '*.log' | head -n 20"
kubectl exec -n monitoring $pod -- sh -c "cat /etc/promtail/promtail.yaml"
kubectl exec -n monitoring $pod -- sh -c "cat /run/promtail/positions.yaml | head -n 120"
```
### 3) Promtail Web 诊断页(targets / service-discovery
```powershell
$pod=(kubectl get pod -n monitoring -l app=promtail -o jsonpath='{.items[0].metadata.name}')
$job=Start-Job -ScriptBlock { param($p) kubectl port-forward -n monitoring pod/$p 19080:9080 } -ArgumentList $pod
Start-Sleep -Seconds 3
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:19080/targets | Select-Object -ExpandProperty Content
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:19080/service-discovery | Select-Object -ExpandProperty Content
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
```
### 4) RBAC 实测
```powershell
kubectl auth can-i list pods --as=system:serviceaccount:monitoring:promtail --all-namespaces
kubectl auth can-i watch pods --as=system:serviceaccount:monitoring:promtail --all-namespaces
kubectl auth can-i list namespaces --as=system:serviceaccount:monitoring:promtail
kubectl auth can-i get nodes --as=system:serviceaccount:monitoring:promtail
```
### 5) 应用修复并滚动重启 Promtail
```powershell
kubectl apply -f deploy/k8s/monitoring/promtail.yaml
kubectl rollout restart ds/promtail -n monitoring
kubectl rollout status ds/promtail -n monitoring --timeout=120s
kubectl logs -n monitoring -l app=promtail --tail=120
```
### 6) Loki API 直连验证
```powershell
$job=Start-Job -ScriptBlock { kubectl port-forward -n monitoring svc/loki 13100:3100 }
Start-Sleep -Seconds 3
Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:13100/loki/api/v1/query_range?query=%7Bjob%3D%22kubernetes-pods%22%7D&limit=10" | Select-Object -ExpandProperty Content
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
```
### 7) 按 app 标签验证
```powershell
$job=Start-Job -ScriptBlock { kubectl port-forward -n monitoring svc/loki 13100:3100 }
Start-Sleep -Seconds 3
Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:13100/loki/api/v1/query_range?query=%7Bjob%3D%22kubernetes-pods%22%2Capp%3D~%22.+%22%7D&limit=5" | Select-Object -ExpandProperty Content
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
```
---
## 六、Grafana 查询建议
建议先放大时间范围(Last 6 hours / Last 24 hours),再逐步收敛:
```logql
{job="kubernetes-pods"}
{job="kubernetes-pods", namespace="juwan"}
{job="kubernetes-pods", app="user-rpc"}
{job="kubernetes-pods", app=~"user-rpc|snowflake|email-mq"} |= "error"
```
---
## 七、后续优化建议
1. 当前 Loki 使用 `emptyDir`,重建后数据会丢失;生产建议改 PVC 持久化。
2. 可以补充 Promtail 的 `drop` 规则,减少噪音日志(如健康检查日志)。
3. 建议在 Grafana 中预置业务 Dashboard 与告警规则(按 app + error rate)。
---
## 八、结论
本次无日志的核心问题不在 Loki 或 Grafana,而在 Promtail 采集链路:
- 发现目标不稳定 + 日志文件符号链接目标未挂载
完成上述修复后,已可通过 Loki API 查到日志,并支持按 `app` 维度查询。
-174
View File
@@ -1,174 +0,0 @@
# Loki 使用指南(日志查看)
本文说明在当前项目中如何使用 Loki 查看 Kubernetes 日志,包括 Grafana 查询、LogQL 常用语句、命令行验证与常见排错。
---
## 1. 日志链路说明
当前日志链路:
- Promtail 采集节点日志文件
- Loki 存储与检索日志
- Grafana 作为查询与展示入口
相关配置文件:
- `deploy/k8s/monitoring/promtail.yaml`
- `deploy/k8s/monitoring/loki.yaml`
- `deploy/k8s/monitoring/grafana.yaml`
---
## 2. 快速开始(Grafana 查看日志)
### 步骤 1:确认监控组件运行
```bash
kubectl get pods -n monitoring
```
至少应看到 `promtail``loki``grafana``Running`
### 步骤 2:打开 Grafana
```bash
kubectl port-forward -n monitoring svc/grafana 3000:3000
```
浏览器打开:`http://localhost:3000`
默认账号密码(按现有配置):
- 用户名:`admin`
- 密码:`change-me`
### 步骤 3:进入 Explore 查询
- 左侧菜单进入 **Explore**
- 数据源选择 **Loki**
- 时间范围建议先设为 **Last 6 hours** 或 **Last 24 hours**
- 输入 LogQL 查询并点击 **Run query**
---
## 3. 常用 LogQL 查询语句
### 3.1 全量日志
```logql
{job="kubernetes-pods"}
```
### 3.2 按命名空间过滤
```logql
{job="kubernetes-pods", namespace="juwan"}
```
### 3.3 按服务(app 标签)过滤
```logql
{job="kubernetes-pods", app="user-rpc"}
```
### 3.4 多服务联合过滤
```logql
{job="kubernetes-pods", app=~"user-rpc|snowflake|email-mq"}
```
### 3.5 按容器名过滤
```logql
{job="kubernetes-pods", container="user-rpc"}
```
### 3.6 关键字过滤(错误日志)
```logql
{job="kubernetes-pods", namespace="juwan"} |= "error"
```
### 3.7 多关键字正则过滤
```logql
{job="kubernetes-pods", namespace="juwan"} |~ "(error|panic|fatal|timeout)"
```
### 3.8 统计最近 5 分钟错误量(按 app)
```logql
sum by (app) (count_over_time({job="kubernetes-pods"} |~ "(?i)error|panic|fatal" [5m]))
```
---
## 4. 不经过 Grafana 的直连验证(Loki API
用于区分“Grafana 查询问题”与“日志未入库问题”。
### 4.1 端口转发 Loki
```bash
kubectl port-forward -n monitoring svc/loki 3100:3100
```
### 4.2 查询是否有流数据
```bash
curl "http://127.0.0.1:3100/loki/api/v1/query_range?query={job=\"kubernetes-pods\"}&limit=10"
```
### 4.3 查询 app 标签流
```bash
curl "http://127.0.0.1:3100/loki/api/v1/query_range?query={job=\"kubernetes-pods\",app=~\".+\"}&limit=10"
```
如果 API 返回 `result` 非空,说明 Loki 已正常入库。
---
## 5. 常见问题与处理
### 问题 1Grafana 显示 No logs found
建议按顺序检查:
1. 时间范围是否太短(先调大到 6h/24h)
2. 查询标签是否过窄(先用 `{job="kubernetes-pods"}`
3. Promtail 是否正常运行并有 target
4. Loki API 是否能直接查到数据
### 问题 2Promtail 有 Running 但仍无日志
重点检查:
- `promtail` targets 是否 `ready`
- 是否存在 `stat ... no such file or directory`
- 是否挂载日志目录(`/var/log``/var/lib/docker/containers`
- 是否有足够 RBAC 权限(pods/nodes/namespaces 等)
### 问题 3:查不到某个服务日志
建议检查:
- 该服务 pod 是否在运行并产生日志
- `namespace``app` 过滤条件是否正确
- 先用 `namespace` 过滤,再逐步加 `app``container` 条件
---
## 6. 推荐查询习惯
1. 先粗后细:全量 -> namespace -> app -> container -> 关键字
2. 先看时间范围:避免默认 1h 漏查
3. 遇到空结果先用 Loki API 验证入库
4. 保存常用查询到 Grafana Dashboard,便于团队复用
---
## 7. 参考
- Loki 故障排查文档:`docs/loki-log-troubleshooting.md`
-779
View File
@@ -1,779 +0,0 @@
# Redis Sentinel 部署问题诊断与修复报告
**问题日期:** 2026年2月22日
**命名空间:** juwan
**涉及资源:** user-rpc deployment, RedisSentinel
---
## 📋 目录
1. [问题背景](#问题背景)
2. [问题现象](#问题现象)
3. [诊断过程](#诊断过程)
4. [根因分析](#根因分析)
5. [解决方案](#解决方案)
6. [修复步骤](#修复步骤)
7. [验证结果](#验证结果)
8. [后续建议](#后续建议)
---
## 🎯 问题背景
### 部署目标
部署一个简单的三节点 Redis Sentinel 哨兵集群作为缓存服务,供 user-rpc 服务使用。后续如有需要再扩展为分片集群。
### 初始配置
`deploy/k8s/service/user/user-rpc.yaml` 中配置了:
- user-rpc Deployment3副本)
- user-rpc Service
- HPACPU和内存)
- **RedisSentinel 资源**
- PostgreSQL Cluster
---
## 🔴 问题现象
### 执行的操作
```bash
kubectl apply -f .\deploy\k8s\service\user\user-rpc.yaml
```
### 输出结果
```
deployment.apps/user-rpc configured
service/user-rpc-svc unchanged
horizontalpodautoscaler.autoscaling/user-rpc-hpa-c unchanged
horizontalpodautoscaler.autoscaling/user-rpc-hpa-m unchanged
redissentinel.redis.redis.opstreelabs.in/user-redis unchanged
cluster.postgresql.cnpg.io/user-db unchanged
```
### 观察到的异常
查看命名空间资源:
```bash
kubectl get all -n juwan
```
**发现:**
- ✅ user-api pods 正常运行
- ✅ user-rpc pods 正常运行
- ✅ PostgreSQL clusters 正常运行
- ❌ **没有任何 Redis 相关的 Pod**
- ❌ **没有 Redis Service**
---
## 🔍 诊断过程
### 步骤 1:检查 RedisSentinel 资源状态
**目的:** 确认 RedisSentinel 资源是否被成功创建
**命令:**
```bash
kubectl get redissentinel user-redis -n juwan
```
**输出:**
```
NAME AGE
user-redis 9m56s
```
**分析:**
- ✅ RedisSentinel 资源已创建
- ❌ 但没有创建任何 Pod
- **结论:** Operator 没有按照 RedisSentinel 规格创建实际资源
---
### 步骤 2:查看 RedisSentinel 详细信息
**目的:** 检查资源的详细配置和事件
**命令:**
```bash
kubectl describe redissentinel user-redis -n juwan
```
**关键输出:**
```yaml
API Version: redis.redis.opstreelabs.in/v1beta2
Kind: RedisSentinel
Metadata:
Creation Timestamp: 2026-02-22T12:41:47Z
Finalizers:
redisSentinelFinalizer
Generation: 2
Spec:
Cluster Size: 3
Redis Sentinel Config:
Redis Replication Name: user-redis # ⚠️ 关键配置
Master Group Name: myMaster
Redis Port: 6379
Quorum: 2
Events: <none> # ⚠️ 没有任何事件
```
**分析:**
- 配置中引用了 `redisReplicationName: user-redis`
- 没有任何 Events,说明 Operator 可能在等待某些前置条件
- **需要进一步检查依赖资源**
---
### 步骤 3:检查 Secret 资源
**目的:** 确认 RedisSentinel 所需的密码 Secret 是否存在
**命令:**
```bash
kubectl get secret user-redis -n juwan
```
**输出:**
```
NAME TYPE DATA AGE
user-redis Opaque 1 5h48m
```
**分析:**
- ✅ Secret 存在,排除认证配置问题
---
### 步骤 4:查找 Redis Pods
**目的:** 确认是否有 Redis Pod 使用不同的命名规则
**命令:**
```bash
kubectl get pods -n juwan | Select-String redis
```
**输出:**
```
(空输出 - 没有找到任何 Redis Pod)
```
**分析:**
- ❌ 确认没有任何 Redis Pod 被创建
---
### 步骤 5:检查 StatefulSet
**目的:** Redis 通常使用 StatefulSet 部署,检查是否有相关资源
**命令:**
```bash
kubectl get statefulset -n juwan
```
**输出:**
```
No resources found in juwan namespace.
```
**分析:**
- ❌ 没有 StatefulSet 被创建
- **结论:** Operator 完全没有执行创建动作
---
### 步骤 6:检查 Redis Operator 状态
**目的:** 确认 Redis Operator 本身是否正常运行
**命令:**
```bash
kubectl get pods -A | Select-String redis-operator
```
**输出:**
```
default redis-operator-7dcf4468c9-gfbcm 1/1 Running 0 4h50m
ot-operators redis-operator-69bb645f5b-6kxkv 0/1 ErrImageNeverPull 0 4h48m
```
**分析:**
- ✅ default namespace 中的 Operator 正常运行
- ⚠️ ot-operators namespace 中的 Operator 镜像拉取失败(但不影响我们的场景)
---
### 步骤 7:查看 Operator 日志
**目的:** 从 Operator 日志中寻找线索
**命令:**
```bash
kubectl logs redis-operator-7dcf4468c9-gfbcm -n default --tail=50
```
**关键输出:**
```json
{"level":"info","ts":"2026-02-22T08:01:56Z","msg":"Starting Controller","controller":"redissentinel"}
{"level":"info","ts":"2026-02-22T08:01:56Z","msg":"Starting workers","controller":"redissentinel","worker count":1}
```
**分析:**
- ✅ RedisSentinel Controller 已启动
- ✅ 没有错误日志
- ❌ 但也没有处理 user-redis 资源的日志
- **推测:** Operator 在等待某个依赖资源
---
### 步骤 8:检查 RedisReplication 资源(关键发现)
**目的:** 根据 RedisSentinel 配置中的 `redisReplicationName: user-redis`,检查对应的 RedisReplication 是否存在
**命令:**
```bash
kubectl get redisreplication -n juwan
```
**输出:**
```
No resources found in juwan namespace.
```
**分析:**
- ❌ **RedisReplication 资源不存在!**
- 🔎 **这就是问题的根本原因**
---
## 💡 根因分析
### 问题根源
**RedisSentinel 依赖 RedisReplication,但配置中只创建了 RedisSentinel,没有创建 RedisReplication。**
### Redis Operator 架构理解
在 OpsTree Redis Operator 中,资源之间的关系如下:
```
┌─────────────────────────────────────────┐
│ RedisSentinel (哨兵层) │
│ - 3个 Sentinel 节点 │
│ - 负责监控和自动故障转移 │
│ - 引用: redisReplicationName │
└──────────────┬──────────────────────────┘
│ 监控
┌─────────────────────────────────────────┐
│ RedisReplication (数据层) │
│ - 1个 Master + N个 Replica │
│ - 提供实际的缓存服务 │
│ - 主从复制 │
└─────────────────────────────────────────┘
```
### 错误配置的问题
原始配置直接创建了 RedisSentinel,但:
1. **缺少被监控对象:** Sentinel 需要监控一个 RedisReplication 集群
2. **引用不存在的资源:** `redisReplicationName: user-redis` 指向一个不存在的 RedisReplication
3. **Operator 行为:** Operator 发现依赖的 RedisReplication 不存在,因此不会创建 Sentinel Pod
### 为什么没有错误提示?
- CRD 验证只检查语法和字段类型
- 资源引用关系由 Operator 运行时检查
- Operator 采用了"等待依赖"策略,而不是报错
---
## ✅ 解决方案
### 正确的部署顺序
1. **先创建 RedisReplication**(建立 Redis 主从复制集群)
2. **再创建 RedisSentinel**(监控上述复制集群)
### 配置结构
```yaml
# 第一步:创建 Redis 主从复制(数据层)
apiVersion: redis.redis.opstreelabs.in/v1beta2
kind: RedisReplication
metadata:
name: user-redis # Sentinel 将引用这个名称
namespace: juwan
spec:
clusterSize: 3 # 1 Master + 2 Replicas
kubernetesConfig:
image: quay.io/opstree/redis:v7.0.12
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
redisSecret:
name: user-redis
key: password
storage:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi # 每个 Redis 节点 1GB 存储
---
# 第二步:创建 Sentinel 监控(监控层)
apiVersion: redis.redis.opstreelabs.in/v1beta2
kind: RedisSentinel
metadata:
name: user-redis-sentinel # 使用不同的名称避免混淆
namespace: juwan
spec:
clusterSize: 3 # 3个 Sentinel 节点(推荐奇数)
kubernetesConfig:
image: quay.io/opstree/redis-sentinel:v7.0.12 # 使用 Sentinel 专用镜像
redisSentinelConfig:
redisReplicationName: user-redis # 引用上面的 RedisReplication
masterGroupName: mymaster
quorum: "2" # 需要 2 个 Sentinel 同意才能进行故障转移
```
---
## 🔧 修复步骤
### 步骤 1:删除错误的 RedisSentinel 资源
**命令:**
```bash
kubectl delete redissentinel user-redis -n juwan
```
**输出:**
```
redissentinel.redis.redis.opstreelabs.in "user-redis" deleted
```
**说明:** 删除仅创建了 CRD 实例但未创建实际 Pod 的资源
---
### 步骤 2:更新配置文件
修改 `deploy/k8s/service/user/user-rpc.yaml`,将单独的 RedisSentinel 替换为:
1. RedisReplication(数据层)
2. RedisSentinel(监控层)
**变更内容:**
- 添加 `RedisReplication` 资源定义
- 添加 `storage.volumeClaimTemplate` 配置
- 修改 RedisSentinel 的 `metadata.name``user-redis-sentinel`
- 使用正确的 Sentinel 镜像:`quay.io/opstree/redis-sentinel:v7.0.12`
- 完善 Sentinel 配置参数
---
### 步骤 3:应用更新后的配置
**命令:**
```bash
kubectl apply -f .\deploy\k8s\service\user\user-rpc.yaml
```
**输出:**
```
deployment.apps/user-rpc configured
service/user-rpc-svc unchanged
horizontalpodautoscaler.autoscaling/user-rpc-hpa-c unchanged
horizontalpodautoscaler.autoscaling/user-rpc-hpa-m unchanged
redisreplication.redis.redis.opstreelabs.in/user-redis created ✅
redissentinel.redis.redis.opstreelabs.in/user-redis-sentinel created ✅
cluster.postgresql.cnpg.io/user-db unchanged
```
**分析:**
- ✅ RedisReplication 成功创建
- ✅ RedisSentinel 成功创建
- 🎯 两个资源都是新创建(created),符合预期
---
## ✅ 验证结果
### 验证 1:检查 Pod 创建情况(等待 30 秒)
**命令:**
```bash
kubectl get statefulset,pods -n juwan | Select-String -Pattern "user-redis|NAME"
```
**输出:**
```
NAME READY AGE
statefulset.apps/user-redis 3/3 81s ✅
statefulset.apps/user-redis-sentinel-sentinel 3/3 24s ✅
NAME READY STATUS RESTARTS AGE
pod/user-redis-0 2/2 Running 0 80s ✅
pod/user-redis-1 2/2 Running 0 52s ✅
pod/user-redis-2 2/2 Running 0 47s ✅
pod/user-redis-sentinel-sentinel-0 1/1 Running 0 24s ✅
pod/user-redis-sentinel-sentinel-1 1/1 Running 0 8s ✅
pod/user-redis-sentinel-sentinel-2 1/1 Running 0 5s ✅
```
**分析:**
- ✅ **RedisReplication** 创建了 3 个 Poduser-redis-0/1/2
- 每个 Pod 有 2 个容器(2/2):Redis + Exporter
- 所有 Pod 处于 Running 状态
- ✅ **RedisSentinel** 创建了 3 个 Poduser-redis-sentinel-sentinel-0/1/2
- 每个 Pod 有 1 个容器(1/1):Sentinel
- 所有 Pod 处于 Running 状态
- ✅ 创建了 2 个 StatefulSetREADY 状态为 3/3
---
### 验证 2:检查 Service 资源
**命令:**
```bash
kubectl get svc -n juwan | Select-String -Pattern "redis|NAME"
```
**输出:**
```
NAME TYPE CLUSTER-IP PORT(S) AGE
user-redis ClusterIP 10.103.91.84 6379/TCP,9121/TCP 95s ✅
user-redis-additional ClusterIP 10.107.228.48 6379/TCP 95s
user-redis-headless ClusterIP None 6379/TCP 95s ✅
user-redis-master ClusterIP 10.97.120.76 6379/TCP 95s ✅
user-redis-replica ClusterIP 10.100.213.103 6379/TCP 95s ✅
user-redis-sentinel-sentinel ClusterIP 10.105.28.231 26379/TCP 40s ✅
user-redis-sentinel-sentinel-additional ClusterIP 10.97.111.42 26379/TCP 39s
user-redis-sentinel-sentinel-headless ClusterIP None 26379/TCP 41s
```
**Service 功能说明:**
#### Redis 数据层 Service(端口 6379
- **user-redis-master**: 主节点服务,用于写操作
- **user-redis-replica**: 从节点服务,用于读操作
- **user-redis**: 通用访问入口(负载均衡到所有节点)
- **user-redis-headless**: 无头服务,用于 StatefulSet Pod 间通信
- **user-redis-additional**: 额外的访问入口
#### Sentinel 监控层 Service(端口 26379
- **user-redis-sentinel-sentinel**: Sentinel 访问入口
- **user-redis-sentinel-sentinel-headless**: Sentinel 节点间通信
- **user-redis-sentinel-sentinel-additional**: 额外的 Sentinel 访问入口
---
### 验证 3:检查完整的集群状态
**命令:**
```bash
kubectl get all -n juwan
```
**最终状态统计:**
| 资源类型 | 名称 | 数量 | 状态 |
|---------|------|------|------|
| **Deployment** | user-api | 3/3 | ✅ Running |
| **Deployment** | user-rpc | 3/3 | ✅ Running |
| **StatefulSet** | cluster-example (PostgreSQL) | 3/3 | ✅ Running |
| **StatefulSet** | user-db (PostgreSQL) | 3/3 | ✅ Running |
| **StatefulSet** | user-redis (Redis 数据) | 3/3 | ✅ Running |
| **StatefulSet** | user-redis-sentinel-sentinel | 3/3 | ✅ Running |
**Pod 总计:** 18 个(全部 Running
**Service 总计:** 13 个
**HPA 总计:** 6 个
---
## 📊 架构图
### 部署后的 Redis 架构
```
┌────────────────────────────────────────────────────────────┐
│ 应用层 (user-rpc) │
│ │
│ [需要添加 Redis 连接配置] │
└──────────┬─────────────────────────────┬───────────────────┘
│ │
│ 写操作 │ 读操作
↓ ↓
┌─────────────┐ ┌─────────────┐
│ user-redis- │ │ user-redis- │
│ master │ │ replica │
│ Service │ │ Service │
└─────────────┘ └─────────────┘
│ │
└──────────┬──────────────────┘
┌──────────────────────────────────────────┐
│ RedisReplication (数据层) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────┐ │
│ │ Master │→ │ Replica │→ │Replica│ │
│ │ redis-0 │ │ redis-1 │ │redis-2│ │
│ └──────────┘ └──────────┘ └───────┘ │
└──────────────────────────────────────────┘
│ 监控 & 故障转移
┌──────────────────────────────────────────┐
│ RedisSentinel (监控层) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────┐ │
│ │Sentinel-0│ │Sentinel-1│ │Sentinel-2│
│ └──────────┘ └──────────┘ └───────┘ │
│ │
│ Quorum: 2/3 (多数派决策) │
└──────────────────────────────────────────┘
```
---
## 📝 后续建议
### 1. 应用集成 Redis
user-rpc 服务目前还没有配置 Redis 连接,需要:
#### 修改配置文件 `app/users/rpc/etc/pb.yaml`
```yaml
Name: pb.rpc
ListenOn: 0.0.0.0:8080
# 添加 Redis 配置(使用 Sentinel 模式)
Redis:
- Host: user-redis-sentinel-sentinel:26379
Type: sentinel
MasterName: mymaster
Pass: ${REDIS_PASSWORD}
# 或使用主从模式
# Redis:
# - Host: user-redis-master:6379 # 写
# Type: node
# Pass: ${REDIS_PASSWORD}
# - Host: user-redis-replica:6379 # 读
# Type: node
# Pass: ${REDIS_PASSWORD}
Etcd:
Hosts:
- etcd-service:2379 # 需要配置实际的 Etcd 地址
Key: pb.rpc
```
#### 修改 Config 结构 `app/users/rpc/internal/config/config.go`
```go
package config
import (
"github.com/zeromicro/go-zero/core/stores/redis"
"github.com/zeromicro/go-zero/zrpc"
)
type Config struct {
zrpc.RpcServerConf
Redis redis.RedisConf // 添加 Redis 配置
}
```
#### 初始化 Redis 客户端 `app/users/rpc/internal/svc/serviceContext.go`
```go
package svc
import (
"github.com/zeromicro/go-zero/core/stores/redis"
"juwan-backend/app/users/rpc/internal/config"
)
type ServiceContext struct {
Config config.Config
Redis *redis.Redis // 添加 Redis 客户端
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
Redis: redis.MustNewRedis(c.Redis), // 初始化 Redis
}
}
```
#### 更新 Deployment 环境变量
```yaml
# deploy/k8s/service/user/user-rpc.yaml
env:
- name: DB_URI
valueFrom:
secretKeyRef:
name: user-db-app
key: uri
- name: REDIS_PASSWORD # 添加 Redis 密码
valueFrom:
secretKeyRef:
name: user-redis
key: password
```
---
### 2. Redis 性能监控
已启用 Redis Exporter(端口 9121),可以配置 Prometheus 监控:
```yaml
apiVersion: v1
kind: ServiceMonitor
metadata:
name: user-redis-metrics
namespace: juwan
spec:
selector:
matchLabels:
app: user-redis
endpoints:
- port: redis-exporter
interval: 30s
```
**监控指标:**
- redis_up: 实例状态
- redis_connected_clients: 连接数
- redis_memory_used_bytes: 内存使用
- redis_commands_processed_total: 命令处理数
- redis_master_repl_offset: 复制偏移量
---
### 3. 高可用性测试
#### 测试主节点故障转移
```bash
# 1. 查找当前主节点
kubectl exec -it user-redis-sentinel-sentinel-0 -n juwan -- redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
# 2. 模拟主节点故障
kubectl delete pod user-redis-0 -n juwan
# 3. 观察 Sentinel 的故障转移过程
kubectl logs -f user-redis-sentinel-sentinel-0 -n juwan
# 4. 确认新主节点
kubectl exec -it user-redis-sentinel-sentinel-0 -n juwan -- redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
```
#### 预期结果
- Sentinel 检测到主节点下线(5 秒)
- 2/3 Sentinel 节点达成共识(quorum=2
- 自动提升一个从节点为主节点
- 客户端自动重连到新主节点
---
### 4. 扩展为分片集群(未来)
当缓存数据量增长需要横向扩展时,可以迁移到 RedisCluster
```yaml
apiVersion: redis.redis.opstreelabs.in/v1beta2
kind: RedisCluster
metadata:
name: user-redis-cluster
namespace: juwan
spec:
clusterSize: 6 # 3 主 + 3 从
kubernetesConfig:
image: quay.io/opstree/redis:v7.0.12
redisLeader:
replicas: 3
redisFollower:
replicas: 3
storage:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
```
**迁移步骤:**
1. 部署新的 RedisCluster
2. 使用 redis-cli --cluster import 迁移数据
3. 更新应用配置指向新集群
4. 下线旧的 Sentinel 集群
---
### 5. 备份策略
Redis Operator 不提供自动备份,建议配置定时任务:
```bash
# 创建 CronJob 定期执行 BGSAVE
apiVersion: batch/v1
kind: CronJob
metadata:
name: redis-backup
namespace: juwan
spec:
schedule: "0 2 * * *" # 每天凌晨 2 点
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: redis:7.0.12
command:
- /bin/sh
- -c
- |
redis-cli -h user-redis-master -a $REDIS_PASSWORD BGSAVE
# 将 /data/dump.rdb 上传到对象存储
restartPolicy: OnFailure
```
---
## 📚 总结
### 关键经验
1. **理解资源依赖关系:** RedisSentinel 依赖 RedisReplication,部署顺序很重要
2. **资源命名规范:** 使用清晰的名称区分不同层次的资源(如 user-redis 和 user-redis-sentinel
3. **诊断思路:**
- 从现象(Pod 缺失)→ 资源状态(CRD 存在)→ Operator 日志 → 依赖检查
- 逐层排查,最终定位到 RedisReplication 缺失
4. **验证完整性:** 不仅要检查 Pod,还要验证 Service、StatefulSet 等所有相关资源
### 文档价值
本文档可用于:
- ✅ 团队知识传承
- ✅ 类似问题的快速排查手册
- ✅ 新成员的 Redis Operator 学习资料
- ✅ 事后复盘和经验总结
---
**最后更新时间:** 2026年2月22日
**文档状态:** ✅ 问题已解决,Redis 集群运行正常
**下一步行动:** 配置应用连接 Redis
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff