Merge branch 'main' into jenkins/init
This commit is contained in:
+35
-42
@@ -4,63 +4,54 @@
|
|||||||
|
|
||||||
- Docker(需要 buildx)
|
- Docker(需要 buildx)
|
||||||
- Python 3(构建脚本)
|
- Python 3(构建脚本)
|
||||||
|
- Git(含 submodule:首次需 `git submodule update --init --recursive`)
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd deploy/dev
|
cd deploy/dev
|
||||||
|
|
||||||
# 1. 构建所有微服务镜像
|
# 1. 构建所有镜像(默认 8 并行,可通过 BAKE_BATCH_SIZE 调整)
|
||||||
# 默认 8 并行,可通过环境变量 BAKE_BATCH_SIZE 调整
|
|
||||||
python3 build.py
|
python3 build.py
|
||||||
|
|
||||||
# 2. 启动
|
# 2. 启动
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 3. 查看状态
|
# 3. 通过网关访问
|
||||||
docker compose ps
|
open http://127.0.0.1:18080
|
||||||
|
|
||||||
# 4. 通过网关访问
|
|
||||||
curl http://127.0.0.1:18080/healthz
|
|
||||||
|
|
||||||
# 5. 停止
|
|
||||||
docker compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
构建脚本会扫描 `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` 入口。
|
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 端口映射
|
## 端口映射
|
||||||
|
|
||||||
| 服务 | 宿主机端口 |
|
| 服务 | 宿主机端口 | 说明 |
|
||||||
| ---------------- | ---------------- |
|
| ---------------- | ---------------- | ---------------------------------- |
|
||||||
| PostgreSQL | 15432 |
|
| Envoy Gateway | 18080 | 浏览器入口,`/api/*`、`/ws/*`、前端静态都从这里出 |
|
||||||
| Redis | 16379 |
|
| Redis | 16379 | 共享会话与验证码 |
|
||||||
| Kafka | 19092 |
|
| MongoDB | 27017 | chat 消息持久化 |
|
||||||
| Envoy Gateway | 18080 |
|
| Kafka | 19092 | email-mq 任务队列 |
|
||||||
| users-api | 18801 |
|
| ratelimit | 18081, 16070 | 限流服务 |
|
||||||
| player-api | 18802 |
|
| users-api | 18801 | 直连调试入口,不经认证链路 |
|
||||||
| game-api | 18803 |
|
| player-api | 18802 | |
|
||||||
| shop-api | 18804 |
|
| game-api | 18803 | |
|
||||||
| order-api | 18805 |
|
| shop-api | 18804 | |
|
||||||
| wallet-api | 18806 |
|
| order-api | 18805 | |
|
||||||
| community-api | 18807 |
|
| wallet-api | 18806 | |
|
||||||
| objectstory-api | 18808 |
|
| community-api | 18807 | |
|
||||||
| email-api | 18809 |
|
| objectstory-api | 18808 | |
|
||||||
| chat-api | 18810, 18443/udp |
|
| email-api | 18809 | |
|
||||||
| review-api | 18811 |
|
| chat-api | 18810, 18889, 18443/udp | |
|
||||||
| dispute-api | 18812 |
|
| review-api | 18811 | |
|
||||||
| notification-api | 18813 |
|
| dispute-api | 18812 | |
|
||||||
| search-api | 18814 |
|
| 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_PASSWORD | 管理员密码 | admin123 |
|
||||||
| ADMIN_EMAIL | 管理员邮箱 | admin@juwan.dev |
|
| ADMIN_EMAIL | 管理员邮箱 | admin@juwan.dev |
|
||||||
|
|
||||||
|
默认 admin 固定 ID `100000`,拥有消费者、打手、店主三种身份全部开通,并预置了店铺、服务、钱包、帖子等演示数据,可直接用于完整链路联调。
|
||||||
|
|
||||||
## 认证
|
## 认证
|
||||||
|
|
||||||
登录和注册通过 `users-api` 下发 `JToken` Cookie。`envoy-gateway` 负责 JWT 校验并注入认证头,`authz-adapter` 做会话态二次校验,后端服务只消费 `x-auth-user-id` 等头。
|
登录和注册通过 `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` 请求头里发后续请求。
|
注册和密码重置都需要先调验证码接口拿到 `requestId`,再把它放到 `X-Request-Id` 请求头里发后续请求。
|
||||||
|
|
||||||
## 数据库初始化
|
## 数据库与初始化
|
||||||
|
|
||||||
首次启动时 PostgreSQL 会自动执行 `desc/sql/` 下的建表语句。如需重新初始化,删除 volume 后重启:
|
每个 per-domain PostgreSQL 首次启动时,通过挂载 `desc/sql/<domain>/` 和 `deploy/dev/fixture/<domain>.sql` 自动完成建表与演示数据导入。如需完全重置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
HARBOR_REGISTRY=harbor.example.com
|
|
||||||
HARBOR_PROJECT=juwan
|
|
||||||
IMAGE_NAME=st-1-example
|
|
||||||
IMAGE_TAG=latest
|
|
||||||
@@ -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`。
|
|
||||||
@@ -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
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
# Operator 安装与示例使用
|
|
||||||
|
|
||||||
本文档提供 Strimzi Operator 与 MongoDB Community Operator 的两种安装方式:
|
|
||||||
|
|
||||||
- Helm 安装
|
|
||||||
- kubectl 安装
|
|
||||||
|
|
||||||
> 示例资源文件位于 `deploy/example`,默认使用 `juwan` 命名空间。
|
|
||||||
> 请先确保你的 Operator 能 watch 到 `juwan`,否则请改 namespace 或调整 Operator watch 范围。
|
|
||||||
|
|
||||||
## 1) Strimzi Operator(Kafka)
|
|
||||||
|
|
||||||
### 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) 卸载(可选)
|
|
||||||
|
|
||||||
### Strimzi(Helm 安装场景)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm uninstall strimzi-kafka-operator -n kafka
|
|
||||||
```
|
|
||||||
|
|
||||||
### MongoDB Operator(Helm 安装场景)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm uninstall mongodb-kubernetes-operator -n mongodb
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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";
|
|
||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩社区服务"
|
||||||
|
desc: "处理帖子、评论、点赞等社区互动。ID 字段(int64)以 string 传输"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
PathId {
|
PathId {
|
||||||
Id int64 `path:"id"`
|
Id int64 `path:"id"`
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩争议服务"
|
||||||
|
desc: "处理订单争议申诉与仲裁。ID 字段(int64)以 string 传输"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
DisputePathId {
|
DisputePathId {
|
||||||
Id int64 `path:"id"`
|
Id int64 `path:"id"`
|
||||||
|
|||||||
+695
-964
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-84
@@ -1,121 +1,139 @@
|
|||||||
{
|
{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"title": "聚玩邮件服务",
|
||||||
|
"description": "处理邮件验证码发送与密码找回",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"schemes": [
|
||||||
|
"http",
|
||||||
|
"https"
|
||||||
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"swagger": "2.0",
|
|
||||||
"info": {
|
|
||||||
"version": "1.0"
|
|
||||||
},
|
|
||||||
"basePath": "/",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/auth/forgot-password/send": {
|
"/api/v1/auth/forgot-password/send": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "忘记密码-发送验证码",
|
"summary": "忘记密码-发送验证码",
|
||||||
"operationId": "emailForgotPassword",
|
"operationId": "ForgotPassword",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "A successful response.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/EmptyResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/ForgotPasswordReq"
|
||||||
"required": [
|
|
||||||
"email"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"email": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"tags": [
|
||||||
"200": {
|
"email"
|
||||||
"description": "",
|
]
|
||||||
"schema": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/email/verification-code/send": {
|
"/api/v1/email/verification-code/send": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "向用户邮箱发送验证码,支持注册、登录、重置密码、绑定邮箱等场景",
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "发送邮箱验证码",
|
"summary": "发送邮箱验证码",
|
||||||
"operationId": "emailSendVerificationCode",
|
"description": "向用户邮箱发送验证码,支持注册、登录、重置密码、绑定邮箱等场景",
|
||||||
|
"operationId": "SendVerificationCode",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "A successful response.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/SendVerificationCodeResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/SendVerificationCodeReq"
|
||||||
"required": [
|
|
||||||
"email",
|
|
||||||
"scene"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"email": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"scene": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"tags": [
|
||||||
"200": {
|
"email"
|
||||||
"description": "",
|
]
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"expireInSec": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"requestId": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-date": "2026-04-22 22:30:26",
|
"definitions": {
|
||||||
"x-description": "This is a goctl generated swagger file.",
|
"EmptyResp": {
|
||||||
"x-github": "https://github.com/zeromicro/go-zero",
|
"type": "object",
|
||||||
"x-go-zero-doc": "https://go-zero.dev/",
|
"title": "EmptyResp"
|
||||||
"x-goctl-version": "1.10.1"
|
},
|
||||||
}
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+242
-173
@@ -1,213 +1,282 @@
|
|||||||
{
|
{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"title": "聚玩游戏服务",
|
||||||
|
"description": "管理游戏目录与分类。ID 字段(int64)以 string 传输",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"schemes": [
|
||||||
|
"http",
|
||||||
|
"https"
|
||||||
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"swagger": "2.0",
|
|
||||||
"info": {
|
|
||||||
"version": "1.0"
|
|
||||||
},
|
|
||||||
"basePath": "/",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/games": {
|
"/api/v1/games/": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "获取游戏列表",
|
"summary": "获取游戏列表",
|
||||||
"operationId": "gameListGames",
|
"operationId": "ListGames",
|
||||||
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "A successful response.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/GameListResp"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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": {
|
"post": {
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "创建游戏",
|
"summary": "创建游戏",
|
||||||
"operationId": "gameCreateGame",
|
"operationId": "CreateGame",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "A successful response.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/Game"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/Game"
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"icon",
|
|
||||||
"category"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"category": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"icon": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"tags": [
|
||||||
"200": {
|
"game"
|
||||||
"description": "",
|
]
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"category": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"icon": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/games/{id}": {
|
"/api/v1/games/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "获取游戏详情",
|
"summary": "获取游戏详情",
|
||||||
"operationId": "gameGetGame",
|
"operationId": "GetGame",
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"name": "id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "A successful response.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/Game"
|
||||||
"properties": {
|
|
||||||
"category": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"icon": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"game"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-date": "2026-04-22 22:30:23",
|
"definitions": {
|
||||||
"x-description": "This is a goctl generated swagger file.",
|
"EmptyResp": {
|
||||||
"x-github": "https://github.com/zeromicro/go-zero",
|
"type": "object",
|
||||||
"x-go-zero-doc": "https://go-zero.dev/",
|
"title": "EmptyResp"
|
||||||
"x-goctl-version": "1.10.1"
|
},
|
||||||
}
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +1,132 @@
|
|||||||
{
|
{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"title": "文件服务",
|
||||||
|
"description": "处理文件上传与获取",
|
||||||
|
"version": "v1"
|
||||||
|
},
|
||||||
|
"schemes": [
|
||||||
|
"http",
|
||||||
|
"https"
|
||||||
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"swagger": "2.0",
|
|
||||||
"info": {
|
|
||||||
"title": "文件服务",
|
|
||||||
"version": "v1"
|
|
||||||
},
|
|
||||||
"basePath": "/",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/files": {
|
"/api/v1/files": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "文件获取接口 (如果是私有文件,通过此接口获取或重定向)",
|
"summary": "文件获取接口 (如果是私有文件,通过此接口获取或重定向)",
|
||||||
"operationId": "fileGetFile",
|
"operationId": "GetFile",
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"name": "key",
|
|
||||||
"in": "query",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "A successful response.",
|
||||||
"schema": {}
|
"schema": {}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/upload": {
|
"/api/v1/upload": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
|
||||||
"application/x-www-form-urlencoded"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "文件上传接口",
|
"summary": "文件上传接口",
|
||||||
"operationId": "fileUpload",
|
"operationId": "Upload",
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"enum": [
|
|
||||||
"avatar",
|
|
||||||
"chat",
|
|
||||||
"post",
|
|
||||||
"verification",
|
|
||||||
"dispute"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"description": "文件类型限制",
|
|
||||||
"name": "type",
|
|
||||||
"in": "formData",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "A successful response.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/UploadResp"
|
||||||
"properties": {
|
|
||||||
"url": {
|
|
||||||
"description": "返回 CDN 地址或访问地址",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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",
|
"definitions": {
|
||||||
"x-description": "This is a goctl generated swagger file.",
|
"GetFileReq": {
|
||||||
"x-github": "https://github.com/zeromicro/go-zero",
|
"type": "object",
|
||||||
"x-go-zero-doc": "https://go-zero.dev/",
|
"properties": {
|
||||||
"x-goctl-version": "1.10.1"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+561
-775
File diff suppressed because it is too large
Load Diff
+661
-1025
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+716
-959
File diff suppressed because it is too large
Load Diff
+864
-1013
File diff suppressed because it is too large
Load Diff
+282
-177
@@ -1,234 +1,339 @@
|
|||||||
{
|
{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"title": "钱包服务",
|
||||||
|
"description": "处理钱包充值相关。ID 字段(int64)以 string 传输",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"schemes": [
|
||||||
|
"http",
|
||||||
|
"https"
|
||||||
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"swagger": "2.0",
|
|
||||||
"info": {
|
|
||||||
"title": "钱包服务",
|
|
||||||
"version": "1.0"
|
|
||||||
},
|
|
||||||
"basePath": "/",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/wallet/balance": {
|
"/api/v1/wallet/balance": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "获取余额",
|
"summary": "获取余额",
|
||||||
"operationId": "walletGetBalance",
|
"operationId": "GetBalance",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "A successful response.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/WalletBalance"
|
||||||
"properties": {
|
|
||||||
"balance": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"frozenBalance": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [
|
||||||
|
"wallet"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/wallet/topup": {
|
"/api/v1/wallet/topup": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "充值",
|
"summary": "充值",
|
||||||
"operationId": "walletTopup",
|
"operationId": "Topup",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "A successful response.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/EmptyResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/TopupReq"
|
||||||
"required": [
|
|
||||||
"amount",
|
|
||||||
"method"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"amount": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"method": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"tags": [
|
||||||
"200": {
|
"wallet"
|
||||||
"description": "",
|
]
|
||||||
"schema": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/wallet/transactions": {
|
"/api/v1/wallet/transactions": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "获取流水",
|
"summary": "获取流水",
|
||||||
"operationId": "walletListTransactions",
|
"operationId": "ListTransactions",
|
||||||
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "A successful response.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/TransactionListResp"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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": {
|
"/api/v1/wallet/withdraw": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"schemes": [
|
|
||||||
"https"
|
|
||||||
],
|
|
||||||
"summary": "提现",
|
"summary": "提现",
|
||||||
"operationId": "walletWithdraw",
|
"operationId": "Withdraw",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "A successful response.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/EmptyResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/TopupReq"
|
||||||
"required": [
|
|
||||||
"amount",
|
|
||||||
"method"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"amount": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"method": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"tags": [
|
||||||
"200": {
|
"wallet"
|
||||||
"description": "",
|
]
|
||||||
"schema": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-date": "2026-04-22 22:30:25",
|
"definitions": {
|
||||||
"x-description": "This is a goctl generated swagger file.",
|
"EmptyResp": {
|
||||||
"x-github": "https://github.com/zeromicro/go-zero",
|
"type": "object",
|
||||||
"x-go-zero-doc": "https://go-zero.dev/",
|
"title": "EmptyResp"
|
||||||
"x-goctl-version": "1.10.1"
|
},
|
||||||
}
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
syntax = "v1"
|
syntax = "v1"
|
||||||
|
|
||||||
info (
|
info (
|
||||||
|
title: "聚玩邮件服务"
|
||||||
|
desc: "处理邮件验证码发送与密码找回"
|
||||||
author: "Asadz"
|
author: "Asadz"
|
||||||
date: "2024-06-19"
|
date: "2024-06-19"
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩游戏服务"
|
||||||
|
desc: "管理游戏目录与分类。ID 字段(int64)以 string 传输"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Game {
|
Game {
|
||||||
Id int64 `json:"id,string,optional"`
|
Id int64 `json:"id,string,optional"`
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩通知服务"
|
||||||
|
desc: "管理站内消息通知。ID 字段(int64)以 string 传输"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
PathId {
|
PathId {
|
||||||
Id int64 `path:"id"`
|
Id int64 `path:"id"`
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import "common.api"
|
|||||||
|
|
||||||
info (
|
info (
|
||||||
title: "聚玩订单服务"
|
title: "聚玩订单服务"
|
||||||
desc: "处理订单业务"
|
desc: "处理订单业务。ID 字段(int64)以 string 传输"
|
||||||
author: "Asadz"
|
author: "Asadz"
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "v1"
|
|||||||
|
|
||||||
info (
|
info (
|
||||||
title: "聚玩打手服务"
|
title: "聚玩打手服务"
|
||||||
desc: "聚玩用户服务处理打手信息管理、服务发布及订单相关接口"
|
desc: "聚玩用户服务处理打手信息管理、服务发布及订单相关接口。ID 字段(int64)以 string 传输"
|
||||||
author: "Asadz"
|
author: "Asadz"
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩评价服务"
|
||||||
|
desc: "处理订单评价与评分。ID 字段(int64)以 string 传输"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
ReviewPathId {
|
ReviewPathId {
|
||||||
Id int64 `path:"id"`
|
Id int64 `path:"id"`
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩搜索服务"
|
||||||
|
desc: "内容搜索与首页推荐"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
PathIDReq {
|
PathIDReq {
|
||||||
Id int64 `path:"id"`
|
Id int64 `path:"id"`
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ syntax = "v1"
|
|||||||
|
|
||||||
import "common.api"
|
import "common.api"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "聚玩店铺服务"
|
||||||
|
desc: "管理店铺信息、员工邀请、模板配置。ID 字段(int64)以 string 传输"
|
||||||
|
author: "Asadz"
|
||||||
|
version: "1.0"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
ShopProfile {
|
ShopProfile {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "v1"
|
|||||||
|
|
||||||
info (
|
info (
|
||||||
title: "聚玩用户服务"
|
title: "聚玩用户服务"
|
||||||
desc: "处理用户注册、登录、个人信息管理及关注系统"
|
desc: "处理用户注册、登录、个人信息管理及关注系统。ID 字段(int64)以 string 传输"
|
||||||
author: "Asadz"
|
author: "Asadz"
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ syntax = "v1"
|
|||||||
|
|
||||||
info (
|
info (
|
||||||
title: "钱包服务"
|
title: "钱包服务"
|
||||||
desc: "处理钱包充值相关"
|
desc: "处理钱包充值相关。ID 字段(int64)以 string 传输"
|
||||||
author: "Asadz"
|
author: "Asadz"
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 内完成全部逻辑)。
|
|
||||||
|
|
||||||
推荐:**先并存**,稳定后再决定是否简化。
|
|
||||||
@@ -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` 了解完整的项目架构和工作流!
|
|
||||||
@@ -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
@@ -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`.
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- 镜像仓库地址错误或不可访问
|
|
||||||
- 镜像仓库需要特定的网络配置
|
|
||||||
- 仓库服务器离线或配置不当
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #3:Redis 部署失败 ❌ 需要诊断
|
|
||||||
|
|
||||||
**现象**:
|
|
||||||
- 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 中添加完整的资源定义,避免手工创建
|
|
||||||
|
|
||||||
@@ -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,请放大时间窗口并放宽标签条件
|
|
||||||
|
|
||||||
### 问题 3:Loki 查不到但 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 基线与上限
|
|
||||||
|
|
||||||
- 两个 HPA(CPU / 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
@@ -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-redis(ClusterIP)
|
|
||||||
|
|
||||||
**基本信息:**
|
|
||||||
```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)和 Exporter(9121)
|
|
||||||
- ⚠️ 可能把写请求轮询到从节点导致失败
|
|
||||||
|
|
||||||
**适用场景:**
|
|
||||||
- 监控抓取(Prometheus 从 9121 端口抓指标)
|
|
||||||
- 不关心读写分离的简单查询
|
|
||||||
|
|
||||||
**为什么有 2 个端口?**
|
|
||||||
```
|
|
||||||
6379: Redis 数据服务
|
|
||||||
9121: Prometheus Exporter 监控端口
|
|
||||||
└─ 暴露 Redis 性能指标给 Prometheus
|
|
||||||
(redis_up, redis_memory_used, etc.)
|
|
||||||
```
|
|
||||||
|
|
||||||
**不用这个的原因:**
|
|
||||||
```
|
|
||||||
❌ 如果直接使用 user-redis 进行读写:
|
|
||||||
├─ 写请求可能被路由到从节点 (error)
|
|
||||||
├─ 无法进行故障自动转移
|
|
||||||
└─ 依赖于手动更新配置
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2️⃣ user-redis-additional(ClusterIP)
|
|
||||||
|
|
||||||
**基本信息:**
|
|
||||||
```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-headless(ClusterIP: 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-master(ClusterIP)
|
|
||||||
|
|
||||||
**基本信息:**
|
|
||||||
```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-replica(ClusterIP)
|
|
||||||
|
|
||||||
**基本信息:**
|
|
||||||
```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-sentinel(ClusterIP)⭐⭐⭐
|
|
||||||
|
|
||||||
**基本信息:**
|
|
||||||
```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-additional(ClusterIP)
|
|
||||||
|
|
||||||
**说明:** 功能同 `user-redis-sentinel-sentinel`,备用入口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 8️⃣ user-redis-sentinel-sentinel-headless(ClusterIP: 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 3:Service 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
|
|
||||||
@@ -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` 维度查询。
|
|
||||||
@@ -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. 常见问题与处理
|
|
||||||
|
|
||||||
### 问题 1:Grafana 显示 No logs found
|
|
||||||
|
|
||||||
建议按顺序检查:
|
|
||||||
|
|
||||||
1. 时间范围是否太短(先调大到 6h/24h)
|
|
||||||
2. 查询标签是否过窄(先用 `{job="kubernetes-pods"}`)
|
|
||||||
3. Promtail 是否正常运行并有 target
|
|
||||||
4. Loki API 是否能直接查到数据
|
|
||||||
|
|
||||||
### 问题 2:Promtail 有 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`
|
|
||||||
@@ -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 Deployment(3副本)
|
|
||||||
- user-rpc Service
|
|
||||||
- HPA(CPU和内存)
|
|
||||||
- **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 个 Pod(user-redis-0/1/2)
|
|
||||||
- 每个 Pod 有 2 个容器(2/2):Redis + Exporter
|
|
||||||
- 所有 Pod 处于 Running 状态
|
|
||||||
- ✅ **RedisSentinel** 创建了 3 个 Pod(user-redis-sentinel-sentinel-0/1/2)
|
|
||||||
- 每个 Pod 有 1 个容器(1/1):Sentinel
|
|
||||||
- 所有 Pod 处于 Running 状态
|
|
||||||
- ✅ 创建了 2 个 StatefulSet,READY 状态为 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
+1
-1
Submodule frontend updated: 4f878340e6...7b191c5d6e
Reference in New Issue
Block a user