feat: rbac迁移完成,并已部署至dev服务器

This commit is contained in:
Blizzard
2026-05-01 01:19:50 +08:00
parent f80a3dc064
commit 8b11068fef
250 changed files with 6314 additions and 13072 deletions
+231
View File
@@ -0,0 +1,231 @@
Name: zero-gateway
Host: 0.0.0.0
Port: 8889
Log:
Encoding: plain
Mode: console
# system-rpc 连接(用于写入操作日志)
SystemRpc:
Etcd:
Hosts:
- 192.168.100.127:2379
Key: system.rpc
# JWT 密钥(与各 API 服务 Auth.AccessSecret 一致)
JwtSecret: "9149f2eb-d517-4a50-a03a-231dbcf0d872"
# 跨域配置
Cors:
AllowOrigins:
- "*"
AllowMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
AllowHeaders:
- Content-Type
- Authorization
- X-Requested-With
- X-Client-Id
# 鉴权白名单(无需 Token 的路径,精确匹配)
AuthWhitelist:
- /api/auth/login
- /api/auth/loginByPhone
- /api/auth/miniLogin
- /api/auth/captcha
- /api/plant/callback/wechatpay
# 上游服务路由表(使用 go-zero 官方 HTTP 网关配置)
Upstreams:
# ==================== Auth API (9001) ====================
- Name: auth-api
Http:
Target: 192.168.100.4:9001
Prefix: /api/auth
Timeout: 5000
Mappings:
# 无需鉴权
- Method: POST
Path: /miniLogin
- Method: POST
Path: /loginByPhone
- Method: POST
Path: /login
- Method: GET
Path: /captcha
# 需要鉴权
- Method: GET
Path: /info
- Method: POST
Path: /update
- Method: POST
Path: /changePassword
- Method: GET
Path: /location
- Method: GET
Path: /weather
# ==================== File API (9002) ====================
- Name: file-api
Http:
Target: 192.168.100.4:9002
Prefix: /api/file
Timeout: 30000
Mappings:
- Method: POST
Path: /upload
- Method: POST
Path: /delete
- Method: POST
Path: /list
- Method: GET
Path: /:id
# ==================== System API (9003) ====================
- Name: system-api
Http:
Target: 192.168.100.4:9003
Prefix: /api/sys
Timeout: 5000
Mappings:
# 客户端管理
- Method: POST
Path: /client/create
- Method: POST
Path: /client/update
- Method: POST
Path: /client/delete
- Method: POST
Path: /client/list
# 角色管理
- Method: POST
Path: /role/create
- Method: POST
Path: /role/update
- Method: POST
Path: /role/delete
- Method: POST
Path: /role/list
# 菜单管理
- Method: POST
Path: /menu/create
- Method: POST
Path: /menu/update
- Method: POST
Path: /menu/delete
- Method: GET
Path: /menu/list
- Method: POST
Path: /menu/byRole
# 操作日志
- Method: POST
Path: /log/list
- Method: POST
Path: /log/delete
# 字典管理
- Method: POST
Path: /dict/create
- Method: POST
Path: /dict/update
- Method: POST
Path: /dict/delete
- Method: POST
Path: /dict/list
# 用户管理
- Method: POST
Path: /user/list
- Method: POST
Path: /user/create
- Method: POST
Path: /user/update
- Method: POST
Path: /user/delete
- Method: POST
Path: /user/resetPassword
# ==================== Plant API (9004) ====================
- Name: plant-api
Http:
Target: 192.168.100.4:9004
Prefix: /api/plant
Timeout: 10000
Mappings:
# 回调(无鉴权)
- Method: POST
Path: /callback/wechatpay
# 我的植物
- Method: POST
Path: /my/create
- Method: POST
Path: /my/update
- Method: POST
Path: /my/delete
- Method: POST
Path: /my/list
- Method: GET
Path: /my/:id
- Method: POST
Path: /my/carePlan
- Method: POST
Path: /my/careRecord
- Method: POST
Path: /my/growthRecord
# 百科
- Method: POST
Path: /wiki/list
- Method: GET
Path: /wiki/:id
- Method: GET
Path: /wiki/class/list
- Method: POST
Path: /wiki/class/create
- Method: POST
Path: /wiki/star
# 帖子
- Method: POST
Path: /post/create
- Method: POST
Path: /post/list
- Method: GET
Path: /post/:id
- Method: POST
Path: /post/delete
- Method: POST
Path: /post/comment
- Method: POST
Path: /post/like
# 话题
- Method: GET
Path: /topic/list
- Method: POST
Path: /topic/create
- Method: POST
Path: /topic/delete
# OCR
- Method: POST
Path: /ocr/classify
# 兑换
- Method: POST
Path: /exchange/list
- Method: POST
Path: /exchange/order
# AI
- Method: POST
Path: /ai/chat
- Method: GET
Path: /ai/history
# 用户资料
- Method: GET
Path: /profile/info
- Method: POST
Path: /profile/update
# 等级/徽章配置
- Method: POST
Path: /config/level/list
- Method: POST
Path: /config/badge/list
@@ -0,0 +1,27 @@
package config
import (
"github.com/zeromicro/go-zero/gateway"
"github.com/zeromicro/go-zero/zrpc"
)
// Config zero-gateway 配置,嵌入官方 GatewayConf
type Config struct {
gateway.GatewayConf
// system-rpc 连接(用于写入操作日志)
SystemRpc zrpc.RpcClientConf
// JWT 密钥(与各 API 服务 Auth.AccessSecret 一致,用于网关层鉴权)
JwtSecret string `json:",optional"`
// 跨域配置
Cors struct {
AllowOrigins []string
AllowMethods []string
AllowHeaders []string
}
// 无需鉴权的路径白名单(精确匹配)
AuthWhitelist []string `json:",optional"`
}
@@ -0,0 +1,142 @@
package middleware
import (
"encoding/json"
"net/http"
"strings"
"time"
jwtUtil "sundynix-micro-go/common/utils/jwt"
jwtv5 "github.com/golang-jwt/jwt/v5"
"github.com/zeromicro/go-zero/core/logx"
)
// RefreshTokenHeader 续期后新 Token 放在此响应头里,前端读取后静默替换
const RefreshTokenHeader = "X-Refresh-Token"
// AuthMiddleware 网关鉴权 + 自动续期中间件
type AuthMiddleware struct {
jwtSecret string
whitelist map[string]bool
}
func NewAuthMiddleware(jwtSecret string, whitelist []string) *AuthMiddleware {
wl := make(map[string]bool, len(whitelist))
for _, p := range whitelist {
wl[p] = true
}
return &AuthMiddleware{
jwtSecret: jwtSecret,
whitelist: wl,
}
}
func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// OPTIONS 预检直接放行
if r.Method == http.MethodOptions {
next(w, r)
return
}
// 白名单路径放行(支持精确匹配和 /* 前缀通配)
if m.isWhitelisted(r.URL.Path) {
next(w, r)
return
}
// 解析 Authorization 头
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeUnauthorized(w, "缺少 Authorization 请求头")
return
}
tokenStr := jwtUtil.GetTokenFromHeader(authHeader)
if tokenStr == "" {
writeUnauthorized(w, "Token 格式错误")
return
}
j := jwtUtil.NewJWT(m.jwtSecret)
claims, err := j.ParseToken(tokenStr)
if err != nil {
logx.Infof("[zero-gateway] JWT 解析失败: %v, path: %s", err, r.URL.Path)
writeUnauthorized(w, err.Error())
return
}
// 将用户信息透传到上游,避免上游重复解析 JWT
r.Header.Set("X-User-Id", claims.BaseClaims.ID)
r.Header.Set("X-User-Account", claims.BaseClaims.Account)
// ---- 滑动窗口续期 ----
// 剩余有效时间 < BufferTime(存储在 token claims 里),说明进入缓冲窗口
if newToken, ok := m.tryRefresh(j, claims); ok {
// 在响应头写入新 Token,前端收到后静默替换本地存储的 Token
w.Header().Set(RefreshTokenHeader, newToken)
logx.Infof("[zero-gateway] Token 已续期, userId: %s", claims.BaseClaims.ID)
}
next(w, r)
}
}
// tryRefresh 判断是否需要续期,需要则签发新 Token 并返回
// 续期规则:剩余有效时间 < BufferTime → 以原始有效时长(ExpiresAt - NotBefore)重新签发
func (m *AuthMiddleware) tryRefresh(j *jwtUtil.JWT, claims *jwtUtil.CustomClaims) (string, bool) {
bufferTime := time.Duration(claims.BufferTime) * time.Second
expiresAt := claims.RegisteredClaims.ExpiresAt.Time
remaining := time.Until(expiresAt)
// 未进入缓冲窗口,无需续期
if remaining >= bufferTime {
return "", false
}
// 计算原始有效时长:ExpiresAt - NotBefore ≈ 当初登录时配置的 activeTimeout
notBefore := claims.RegisteredClaims.NotBefore.Time
originalDuration := expiresAt.Sub(notBefore)
// 构建新 Claims,保持 BaseClaims 和 BufferTime 不变,重新计算有效期
newClaims := jwtUtil.CustomClaims{
BaseClaims: claims.BaseClaims,
BufferTime: claims.BufferTime,
RegisteredClaims: jwtv5.RegisteredClaims{
Audience: claims.RegisteredClaims.Audience,
Issuer: claims.RegisteredClaims.Issuer,
NotBefore: jwtv5.NewNumericDate(time.Now()),
ExpiresAt: jwtv5.NewNumericDate(time.Now().Add(originalDuration)),
},
}
newToken, err := j.CreateToken(newClaims)
if err != nil {
logx.Errorf("[zero-gateway] Token 续期失败: %v", err)
return "", false
}
return newToken, true
}
// isWhitelisted 支持精确匹配和 /* 前缀通配
func (m *AuthMiddleware) isWhitelisted(path string) bool {
if m.whitelist[path] {
return true
}
for p := range m.whitelist {
if strings.HasSuffix(p, "/*") && strings.HasPrefix(path, strings.TrimSuffix(p, "*")) {
return true
}
}
return false
}
// writeUnauthorized 返回统一的 401 响应
func writeUnauthorized(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 401,
"msg": msg,
})
}
@@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strings"
)
// CorsMiddleware 跨域中间件
type CorsMiddleware struct {
allowOrigins []string
allowMethods []string
allowHeaders []string
}
func NewCorsMiddleware(origins, methods, headers []string) *CorsMiddleware {
return &CorsMiddleware{
allowOrigins: origins,
allowMethods: methods,
allowHeaders: headers,
}
}
func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
allowed := false
for _, o := range m.allowOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
if allowed {
allowOrigin := origin
if len(m.allowOrigins) == 1 && m.allowOrigins[0] == "*" {
allowOrigin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Access-Control-Allow-Methods", strings.Join(m.allowMethods, ", "))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(m.allowHeaders, ", "))
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "3600")
// 允许前端 JS 读取自定义响应头(默认跨域只能读 6 个安全头)
w.Header().Set("Access-Control-Expose-Headers", "X-Refresh-Token")
}
// 预检请求直接返回 204
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
@@ -0,0 +1,168 @@
package middleware
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"time"
"sundynix-micro-go/app/system/rpc/system"
"sundynix-micro-go/app/system/rpc/systemservice"
jwtUtil "sundynix-micro-go/common/utils/jwt"
"github.com/zeromicro/go-zero/core/logx"
)
// OperationLogMiddleware 操作日志中间件(异步写入 system-rpc)
type OperationLogMiddleware struct {
systemRpc systemservice.SystemService
jwtSecret string
logChan chan *system.CreateOperationRecordReq
}
func NewOperationLogMiddleware(systemRpc systemservice.SystemService, jwtSecret string) *OperationLogMiddleware {
m := &OperationLogMiddleware{
systemRpc: systemRpc,
jwtSecret: jwtSecret,
logChan: make(chan *system.CreateOperationRecordReq, 500),
}
// 启动异步消费者,避免阻塞请求
go m.consumer()
return m
}
func (m *OperationLogMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 跳过健康检查和 OPTIONS 预检
if r.URL.Path == "/health" || r.Method == http.MethodOptions {
next(w, r)
return
}
startTime := time.Now()
// 读取并缓存请求体(限制大小,跳过文件上传)
var bodyStr string
if r.Body != nil && !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 2048))
bodyStr = string(bodyBytes)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // 恢复以供上游读取
}
clientId := r.Header.Get("X-Client-Id")
userId := m.extractUserId(r)
clientIP := getClientIP(r)
// 包装 ResponseWriter 捕获响应状态码和响应体
rw := &responseCapture{ResponseWriter: w, statusCode: http.StatusOK}
next(rw, r)
latency := time.Since(startTime)
record := &system.CreateOperationRecordReq{
ClientId: clientId,
Ip: clientIP,
Method: r.Method,
Path: r.URL.Path,
Status: int32(rw.statusCode),
Latency: latency.Nanoseconds(),
Agent: truncate(r.UserAgent(), 500),
ErrorMessage: rw.errorMsg(),
Body: truncate(bodyStr, 2000),
Resp: truncate(rw.body.String(), 2000),
UserId: userId,
}
// 异步写入,不阻塞响应
select {
case m.logChan <- record:
default:
logx.Error("[zero-gateway] 操作日志缓冲区满,丢弃日志")
}
}
}
// consumer 异步消费操作日志并通过 system-rpc 写入数据库
func (m *OperationLogMiddleware) consumer() {
for record := range m.logChan {
_, err := m.systemRpc.CreateOperationRecord(context.Background(), record)
if err != nil {
logx.Errorf("[zero-gateway] 写入操作日志失败: %v", err)
}
}
}
// extractUserId 从 Authorization 头解析 JWT 获取 userId
func (m *OperationLogMiddleware) extractUserId(r *http.Request) string {
// 优先从鉴权中间件注入的请求头获取(避免重复解析 JWT)
if uid := r.Header.Get("X-User-Id"); uid != "" {
return uid
}
// fallback: 自己解析
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ""
}
tokenStr := jwtUtil.GetTokenFromHeader(authHeader)
if tokenStr == "" {
return ""
}
j := jwtUtil.NewJWT(m.jwtSecret)
claims, err := j.ParseToken(tokenStr)
if err != nil {
return ""
}
return claims.BaseClaims.ID
}
// responseCapture 捕获响应状态码和响应体
type responseCapture struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
}
func (rc *responseCapture) WriteHeader(code int) {
rc.statusCode = code
rc.ResponseWriter.WriteHeader(code)
}
func (rc *responseCapture) Write(b []byte) (int, error) {
if rc.body.Len() < 2048 {
rc.body.Write(b)
}
return rc.ResponseWriter.Write(b)
}
func (rc *responseCapture) errorMsg() string {
if rc.statusCode >= 400 {
return rc.body.String()
}
return ""
}
// getClientIP 获取真实客户端 IP
func getClientIP(r *http.Request) string {
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
if ip := r.Header.Get("X-Real-Ip"); ip != "" {
return ip
}
addr := r.RemoteAddr
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
}
return addr
}
// truncate 截断字符串,防止写入过长内容
func truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"flag"
"fmt"
"sundynix-micro-go/app/system/rpc/systemservice"
"sundynix-micro-go/app/zero-gateway/internal/config"
"sundynix-micro-go/app/zero-gateway/internal/middleware"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stat"
"github.com/zeromicro/go-zero/gateway"
"github.com/zeromicro/go-zero/rest"
"github.com/zeromicro/go-zero/zrpc"
)
var configFile = flag.String("f", "etc/zero-gateway.yaml", "the config file")
func main() {
flag.Parse()
stat.DisableLog()
var c config.Config
conf.MustLoad(*configFile, &c)
// 初始化 system-rpc 客户端(用于异步写入操作日志)
systemRpc := systemservice.NewSystemService(zrpc.MustNewClient(c.SystemRpc))
// 构建各中间件实例
corsMiddleware := middleware.NewCorsMiddleware(
c.Cors.AllowOrigins,
c.Cors.AllowMethods,
c.Cors.AllowHeaders,
)
authMiddleware := middleware.NewAuthMiddleware(c.JwtSecret, c.AuthWhitelist)
opLogMiddleware := middleware.NewOperationLogMiddleware(systemRpc, c.JwtSecret)
// 构建官方 Gateway,注入中间件(执行顺序:CORS → Auth → OpLog → 上游转发)
gw := gateway.MustNewServer(c.GatewayConf,
gateway.WithMiddleware(
rest.Middleware(corsMiddleware.Handle), // 跨域
rest.Middleware(authMiddleware.Handle), // JWT 鉴权
rest.Middleware(opLogMiddleware.Handle), // 操作日志(异步写入 system-rpc)
),
)
defer gw.Stop()
fmt.Println("===== Sundynix Zero-Gateway (Official) =====")
logx.Infof("监听地址: %s:%d", c.Host, c.Port)
logx.Infof("上游服务: %d 个", len(c.Upstreams))
logx.Infof("中间件: CORS | JWT鉴权 | 操作日志→system-rpc")
logx.Infof("鉴权白名单: %d 条", len(c.AuthWhitelist))
for _, u := range c.Upstreams {
if u.Http.Target != "" {
logx.Infof(" [HTTP] %-15s -> %s (prefix: %s, routes: %d)",
u.Name, u.Http.Target, u.Http.Prefix, len(u.Mappings))
}
if u.Grpc.Target != "" {
logx.Infof(" [gRPC] %-15s -> %s (routes: %d)",
u.Name, u.Grpc.Target, len(u.Mappings))
}
}
fmt.Println("=============================================")
gw.Start()
}