fix version 1
This commit is contained in:
@@ -14,12 +14,15 @@ Databases:
|
||||
- host=127.0.0.1 user=postgres password=Weidong1214 dbname=bsm_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai
|
||||
|
||||
Cache: redis://null:Weidong1214@127.0.0.1:6379/1
|
||||
SecretKey: CHANGE_ME
|
||||
|
||||
# session cookie 的 HMAC 密钥,必须由部署通过环境变量提供,不得留空或使用公开占位值。
|
||||
SecretKey: ${BSM_SECRET_KEY}
|
||||
|
||||
# Authorization contains the HS256 signing key and maximum token lifetime in
|
||||
# seconds. Clients send the raw JWT in the Authorization header (no Bearer).
|
||||
# 签名密钥必须由 BSM_JwtSecretKey 环境变量提供;公开样例值会被入口拒绝。
|
||||
Authorization:
|
||||
Key: CHANGE_ME_32_BYTE_JWT_SECRET_KEY
|
||||
Key: ${BSM_JwtSecretKey}
|
||||
Expire: 86400
|
||||
Anonymous:
|
||||
- /passport.Login/Pwd
|
||||
|
||||
150
pkgs/all/etc/default_prod.yaml
Normal file
150
pkgs/all/etc/default_prod.yaml
Normal file
@@ -0,0 +1,150 @@
|
||||
# 生产环境配置:BSM_RuntimeMode=prod 时按 <BSM_Prefix>/etc/default_prod.yaml 读取。
|
||||
# 所有敏感值与部署相关地址一律使用环境变量占位,进程启动时由 os.ExpandEnv 展开;
|
||||
# 未提供 BSM_SECRET_KEY / BSM_JwtSecretKey 时入口会直接拒绝启动。
|
||||
Service: default
|
||||
|
||||
Server:
|
||||
GRPC:
|
||||
BindIP: 0.0.0.0
|
||||
Port: 12000
|
||||
HTTP:
|
||||
BindIP: 0.0.0.0
|
||||
Port: 12001
|
||||
|
||||
Databases:
|
||||
Driver: postgres
|
||||
Source:
|
||||
- ${BSM_DB_DSN}
|
||||
|
||||
# redis 连接串,不带库号时由 SDK 自动 HASH 选择 DB 库。
|
||||
Cache: ${BSM_REDIS_DSN}
|
||||
|
||||
# session cookie 的 HMAC 密钥。
|
||||
SecretKey: ${BSM_SECRET_KEY}
|
||||
|
||||
# HS256 签名密钥与 token 最长有效期(秒);客户端把裸 JWT 放在 Authorization 头中。
|
||||
Authorization:
|
||||
Key: ${BSM_JwtSecretKey}
|
||||
Expire: 86400
|
||||
Anonymous:
|
||||
- /passport.Login/Pwd
|
||||
- /passport.Login/Code
|
||||
- /passport.Login/Quick
|
||||
- /passport.Register/Pwd
|
||||
- /passport.Register/Code
|
||||
- /passport.Forget/Verify
|
||||
- /passport.Forget/Reset
|
||||
- /passport.Verify/Request
|
||||
- /passport.Verify/JumioCallback
|
||||
- /market.Agency/Login
|
||||
- /mall.Staff/Login
|
||||
- /rest/fts/ping
|
||||
- /rest/fts/config
|
||||
- /rest/logs/ping
|
||||
- /rest/mgt/ping
|
||||
- /rest/mgt/session
|
||||
- /rest/mgt/login
|
||||
- /rest/mgt/refresh
|
||||
- /rest/mgt/reset
|
||||
|
||||
# 启用的聚合服务;非空的 BSM_SERVICES 会覆盖本列表,"all" 表示全部启用。
|
||||
Services:
|
||||
- ads
|
||||
- cloud
|
||||
- cms
|
||||
- feedback
|
||||
- fts
|
||||
- initial
|
||||
- logs
|
||||
- mgt
|
||||
- passport
|
||||
- sender
|
||||
- address
|
||||
- mall
|
||||
- market
|
||||
- order
|
||||
- wallet
|
||||
|
||||
# 各模块专项配置平铺在文档根下;共享的数据库/Redis/RPC/APM/Etcd 由入口自动回填。
|
||||
Fts:
|
||||
MinioOss:
|
||||
Site: ${BSM_MINIO_SITE}
|
||||
Endpoint: ${BSM_MINIO_ENDPOINT}
|
||||
AccessKeyId: ${BSM_MINIO_ACCESS_KEY_ID}
|
||||
AccessKeySecret: ${BSM_MINIO_ACCESS_KEY_SECRET}
|
||||
UseSSL: true
|
||||
Local:
|
||||
Site: ${BSM_FTS_LOCAL_SITE}
|
||||
UploadDir: ./uploader/
|
||||
FtsConfig:
|
||||
InputKey: file
|
||||
MaxSize: 5368709120
|
||||
Allows: [.doc, .docx, .txt, .png, .jpg, .jpeg, .gif, .pdf, .xls, .xlsx, .ppt, .pptx, .mp4, .avi, .mkv, .webp, .mov, .wmv, .mp3, .wav]
|
||||
|
||||
Mgt:
|
||||
# 聚合进程只执行建表迁移、不执行模块的 InitData 播种,该开关在聚合下不生效(见 AGENT.md §3.5)。
|
||||
InitRootUser: false
|
||||
SecretKey: ${BSM_MGT_SECRET_KEY}
|
||||
|
||||
Passport:
|
||||
Token:
|
||||
Prefix: /token/
|
||||
Expire: 86400
|
||||
Kyc:
|
||||
Provider: jumio
|
||||
BaseUrl: ${BSM_JUMIO_BASE_URL}
|
||||
ApiSecret: ${BSM_JUMIO_API_SECRET}
|
||||
ApiToken: ${BSM_JUMIO_API_TOKEN}
|
||||
ApiArgs: ${BSM_JUMIO_API_ARGS}
|
||||
WeChatConf:
|
||||
AppID: ${BSM_WECHAT_APPID}
|
||||
AppSecret: ${BSM_WECHAT_APPSECRET}
|
||||
|
||||
Sender:
|
||||
SMTP:
|
||||
default:
|
||||
Endpoint: ${BSM_SMTP_ENDPOINT}
|
||||
Port: 465
|
||||
Username: ${BSM_SMTP_USERNAME}
|
||||
Password: ${BSM_SMTP_PASSWORD}
|
||||
FromAddress: ${BSM_SMTP_FROM_ADDRESS}
|
||||
FromName: BSM
|
||||
SMS:
|
||||
default:
|
||||
Endpoint: ${BSM_SMS_ENDPOINT}
|
||||
AccessKeyId: ${BSM_SMS_ACCESS_KEY_ID}
|
||||
AccessKeySecret: ${BSM_SMS_ACCESS_KEY_SECRET}
|
||||
Region: ${BSM_SMS_REGION}
|
||||
Code:
|
||||
Length: 6
|
||||
Expire: 300
|
||||
MaxSentLimit: 5
|
||||
CokeyKey: code
|
||||
BlackListFilter: [127.0.0.1]
|
||||
|
||||
Wallet:
|
||||
QrCodeSavePath: ./qrcode/
|
||||
WeChat:
|
||||
AppID: ${BSM_WECHAT_APPID}
|
||||
AppSecret: ${BSM_WECHAT_APPSECRET}
|
||||
SerialNo: ${BSM_WECHAT_SERIALNO}
|
||||
APIV3Key: ${BSM_WECHAT_APIV3KEY}
|
||||
PrivateKey: ${BSM_WECHAT_PRIVATEKEY}
|
||||
NotifyURL: ${BSM_WECHAT_NOTIFYURL}
|
||||
Currency: CNY
|
||||
MchID: ${BSM_WECHAT_MCHID}
|
||||
Alipay:
|
||||
AppID: ${BSM_ALIPAY_APPID}
|
||||
AppSecret: ${BSM_ALIPAY_APPSECRET}
|
||||
PrivateKey: ${BSM_ALIPAY_PRIVATEKEY}
|
||||
IsProd: true
|
||||
NotifyURL: ${BSM_ALIPAY_NOTIFYURL}
|
||||
ReturnURL: ${BSM_ALIPAY_RETURNURL}
|
||||
QuitURL: ${BSM_ALIPAY_QUITURL}
|
||||
# 三个支付渠道开关按业务需要显式开启,默认保持关闭。
|
||||
Wallet:
|
||||
Name: default
|
||||
URL: ${BSM_WALLET_URL}
|
||||
AlipyIsOpen: false
|
||||
WalletIsOpen: false
|
||||
WechatpayIsOpen: false
|
||||
150
pkgs/all/etc/default_test.yaml
Normal file
150
pkgs/all/etc/default_test.yaml
Normal file
@@ -0,0 +1,150 @@
|
||||
# 测试环境配置:BSM_RuntimeMode=test 时按 <BSM_Prefix>/etc/default_test.yaml 读取。
|
||||
# 所有敏感值与部署相关地址一律使用环境变量占位,进程启动时由 os.ExpandEnv 展开;
|
||||
# 未提供 BSM_SECRET_KEY / BSM_JwtSecretKey 时入口会直接拒绝启动。
|
||||
Service: default
|
||||
|
||||
Server:
|
||||
GRPC:
|
||||
BindIP: 0.0.0.0
|
||||
Port: 12000
|
||||
HTTP:
|
||||
BindIP: 0.0.0.0
|
||||
Port: 12001
|
||||
|
||||
Databases:
|
||||
Driver: postgres
|
||||
Source:
|
||||
- ${BSM_DB_DSN}
|
||||
|
||||
# redis 连接串,不带库号时由 SDK 自动 HASH 选择 DB 库。
|
||||
Cache: ${BSM_REDIS_DSN}
|
||||
|
||||
# session cookie 的 HMAC 密钥。
|
||||
SecretKey: ${BSM_SECRET_KEY}
|
||||
|
||||
# HS256 签名密钥与 token 最长有效期(秒);客户端把裸 JWT 放在 Authorization 头中。
|
||||
Authorization:
|
||||
Key: ${BSM_JwtSecretKey}
|
||||
Expire: 86400
|
||||
Anonymous:
|
||||
- /passport.Login/Pwd
|
||||
- /passport.Login/Code
|
||||
- /passport.Login/Quick
|
||||
- /passport.Register/Pwd
|
||||
- /passport.Register/Code
|
||||
- /passport.Forget/Verify
|
||||
- /passport.Forget/Reset
|
||||
- /passport.Verify/Request
|
||||
- /passport.Verify/JumioCallback
|
||||
- /market.Agency/Login
|
||||
- /mall.Staff/Login
|
||||
- /rest/fts/ping
|
||||
- /rest/fts/config
|
||||
- /rest/logs/ping
|
||||
- /rest/mgt/ping
|
||||
- /rest/mgt/session
|
||||
- /rest/mgt/login
|
||||
- /rest/mgt/refresh
|
||||
- /rest/mgt/reset
|
||||
|
||||
# 启用的聚合服务;非空的 BSM_SERVICES 会覆盖本列表,"all" 表示全部启用。
|
||||
Services:
|
||||
- ads
|
||||
- cloud
|
||||
- cms
|
||||
- feedback
|
||||
- fts
|
||||
- initial
|
||||
- logs
|
||||
- mgt
|
||||
- passport
|
||||
- sender
|
||||
- address
|
||||
- mall
|
||||
- market
|
||||
- order
|
||||
- wallet
|
||||
|
||||
# 各模块专项配置平铺在文档根下;共享的数据库/Redis/RPC/APM/Etcd 由入口自动回填。
|
||||
Fts:
|
||||
MinioOss:
|
||||
Site: ${BSM_MINIO_SITE}
|
||||
Endpoint: ${BSM_MINIO_ENDPOINT}
|
||||
AccessKeyId: ${BSM_MINIO_ACCESS_KEY_ID}
|
||||
AccessKeySecret: ${BSM_MINIO_ACCESS_KEY_SECRET}
|
||||
UseSSL: true
|
||||
Local:
|
||||
Site: ${BSM_FTS_LOCAL_SITE}
|
||||
UploadDir: ./uploader/
|
||||
FtsConfig:
|
||||
InputKey: file
|
||||
MaxSize: 5368709120
|
||||
Allows: [.doc, .docx, .txt, .png, .jpg, .jpeg, .gif, .pdf, .xls, .xlsx, .ppt, .pptx, .mp4, .avi, .mkv, .webp, .mov, .wmv, .mp3, .wav]
|
||||
|
||||
Mgt:
|
||||
# 聚合进程只执行建表迁移、不执行模块的 InitData 播种,该开关在聚合下不生效(见 AGENT.md §3.5)。
|
||||
InitRootUser: false
|
||||
SecretKey: ${BSM_MGT_SECRET_KEY}
|
||||
|
||||
Passport:
|
||||
Token:
|
||||
Prefix: /token/
|
||||
Expire: 86400
|
||||
Kyc:
|
||||
Provider: jumio
|
||||
BaseUrl: ${BSM_JUMIO_BASE_URL}
|
||||
ApiSecret: ${BSM_JUMIO_API_SECRET}
|
||||
ApiToken: ${BSM_JUMIO_API_TOKEN}
|
||||
ApiArgs: ${BSM_JUMIO_API_ARGS}
|
||||
WeChatConf:
|
||||
AppID: ${BSM_WECHAT_APPID}
|
||||
AppSecret: ${BSM_WECHAT_APPSECRET}
|
||||
|
||||
Sender:
|
||||
SMTP:
|
||||
default:
|
||||
Endpoint: ${BSM_SMTP_ENDPOINT}
|
||||
Port: 465
|
||||
Username: ${BSM_SMTP_USERNAME}
|
||||
Password: ${BSM_SMTP_PASSWORD}
|
||||
FromAddress: ${BSM_SMTP_FROM_ADDRESS}
|
||||
FromName: BSM
|
||||
SMS:
|
||||
default:
|
||||
Endpoint: ${BSM_SMS_ENDPOINT}
|
||||
AccessKeyId: ${BSM_SMS_ACCESS_KEY_ID}
|
||||
AccessKeySecret: ${BSM_SMS_ACCESS_KEY_SECRET}
|
||||
Region: ${BSM_SMS_REGION}
|
||||
Code:
|
||||
Length: 6
|
||||
Expire: 300
|
||||
MaxSentLimit: 5
|
||||
CokeyKey: code
|
||||
BlackListFilter: [127.0.0.1]
|
||||
|
||||
Wallet:
|
||||
QrCodeSavePath: ./qrcode/
|
||||
WeChat:
|
||||
AppID: ${BSM_WECHAT_APPID}
|
||||
AppSecret: ${BSM_WECHAT_APPSECRET}
|
||||
SerialNo: ${BSM_WECHAT_SERIALNO}
|
||||
APIV3Key: ${BSM_WECHAT_APIV3KEY}
|
||||
PrivateKey: ${BSM_WECHAT_PRIVATEKEY}
|
||||
NotifyURL: ${BSM_WECHAT_NOTIFYURL}
|
||||
Currency: CNY
|
||||
MchID: ${BSM_WECHAT_MCHID}
|
||||
Alipay:
|
||||
AppID: ${BSM_ALIPAY_APPID}
|
||||
AppSecret: ${BSM_ALIPAY_APPSECRET}
|
||||
PrivateKey: ${BSM_ALIPAY_PRIVATEKEY}
|
||||
IsProd: true
|
||||
NotifyURL: ${BSM_ALIPAY_NOTIFYURL}
|
||||
ReturnURL: ${BSM_ALIPAY_RETURNURL}
|
||||
QuitURL: ${BSM_ALIPAY_QUITURL}
|
||||
# 三个支付渠道开关按业务需要显式开启,默认保持关闭。
|
||||
Wallet:
|
||||
Name: default
|
||||
URL: ${BSM_WALLET_URL}
|
||||
AlipyIsOpen: false
|
||||
WalletIsOpen: false
|
||||
WechatpayIsOpen: false
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -53,6 +54,17 @@ type AuthorizationConfig struct {
|
||||
|
||||
var Spec SrvConfig
|
||||
|
||||
// publicJwtSecretKeys 是仓库中出现过的公开 JWT 密钥字面量,绝不允许被当作有效签名密钥使用:
|
||||
// - CHANGE_ME_32_BYTE_JWT_SECRET_KEY:历史样例值(恰好 32 字节,能通过长度校验);
|
||||
// - Cblocksmesh2022C:bsm-sdk env.NewEnv() 的内置默认值(SDK 不可改,只能在此拒绝)。
|
||||
var publicJwtSecretKeys = map[string]struct{}{
|
||||
"CHANGE_ME_32_BYTE_JWT_SECRET_KEY": {},
|
||||
"Cblocksmesh2022C": {},
|
||||
}
|
||||
|
||||
// publicSessionSecret 是 session cookie HMAC 密钥的公开占位值。
|
||||
const publicSessionSecret = "CHANGE_ME"
|
||||
|
||||
func New(serviceKey string) {
|
||||
conf.New(serviceKey, &Spec)
|
||||
normalizeListener(&Spec.Server.GRPC)
|
||||
@@ -60,16 +72,30 @@ func New(serviceKey string) {
|
||||
if Spec.Server.GRPC.Addr == Spec.Server.HTTP.Addr {
|
||||
panic("gRPC and HTTP listeners must use different addresses")
|
||||
}
|
||||
if strings.TrimSpace(Spec.Authorization.Key) == "" {
|
||||
panic("Authorization.Key must not be empty")
|
||||
// JWT 是全系统身份凭据,签名密钥必须由部署显式提供:优先取 BSM_JwtSecretKey 环境变量,
|
||||
// 空值与公开样例值一律拒绝启动,避免默认配置直接上线后被用于伪造任意身份。
|
||||
if envKey := strings.TrimSpace(os.Getenv("BSM_JwtSecretKey")); envKey != "" {
|
||||
Spec.Authorization.Key = envKey
|
||||
}
|
||||
Spec.Authorization.Key = strings.TrimSpace(Spec.Authorization.Key)
|
||||
if Spec.Authorization.Key == "" {
|
||||
log.Fatalln("ERROR: JWT secret is not configured; provide it through the BSM_JwtSecretKey environment variable")
|
||||
}
|
||||
if _, isPublic := publicJwtSecretKeys[Spec.Authorization.Key]; isPublic {
|
||||
log.Fatalln("ERROR: JWT secret is a public sample value; provide a private key through the BSM_JwtSecretKey environment variable")
|
||||
}
|
||||
keyLength := len(Spec.Authorization.Key)
|
||||
if keyLength != 16 && keyLength != 24 && keyLength != 32 {
|
||||
panic("Authorization.Key must contain 16, 24, or 32 bytes")
|
||||
log.Fatalln("ERROR: JWT secret must contain 16, 24, or 32 bytes")
|
||||
}
|
||||
if Spec.Authorization.Expire <= 0 {
|
||||
panic("Authorization.Expire must be greater than zero")
|
||||
}
|
||||
// session cookie 的 HMAC 密钥同样不得为空或公开占位值。
|
||||
sessionSecret := strings.TrimSpace(Spec.SecretKey)
|
||||
if sessionSecret == "" || sessionSecret == publicSessionSecret {
|
||||
log.Fatalln("ERROR: SecretKey must not be empty or a public placeholder; provide a private secret through the BSM_SECRET_KEY environment variable")
|
||||
}
|
||||
env.NewEnv().JwtSecretKey = Spec.Authorization.Key
|
||||
coreVars.JwtExpire = time.Duration(Spec.Authorization.Expire) * time.Second
|
||||
// Keep the embedded base address meaningful for module configurations that
|
||||
|
||||
@@ -9,12 +9,16 @@ import (
|
||||
)
|
||||
|
||||
func TestAllDevConfig(t *testing.T) {
|
||||
// dev 配置中的密钥已改为环境变量占位,测试按进程实际展开后再校验结构。
|
||||
t.Setenv("BSM_JwtSecretKey", "0123456789abcdef0123456789abcdef")
|
||||
t.Setenv("BSM_SECRET_KEY", "0123456789abcdef0123456789abcdef")
|
||||
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "etc", "default_dev.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg SrvConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
if err := yaml.Unmarshal([]byte(os.ExpandEnv(string(data))), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Services) == 0 {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bsm/full/pkgs/all/internal/config"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/cache/redis"
|
||||
"git.apinb.com/bsm-sdk/core/types"
|
||||
"git.apinb.com/bsm-sdk/core/vars"
|
||||
"git.apinb.com/bsm-sdk/core/with"
|
||||
"github.com/patrickmn/go-cache"
|
||||
clientv3 "go.etcd.io/etcd/client/v3"
|
||||
@@ -34,8 +36,17 @@ func NewImpl() {
|
||||
// 初始化Redis缓存服务,用于分布式缓存
|
||||
RedisService = with.RedisCache(config.Spec.Cache)
|
||||
|
||||
// 初始化数据库连接,用于数据持久化
|
||||
DBService = with.Databases(config.Spec.Databases, nil)
|
||||
// 初始化数据库连接,用于数据持久化。
|
||||
// 聚合进程不会调用各模块的 impl.NewImpl(),因此在这里打开 SDK 的自动迁移:
|
||||
// 各模块模型在 init() 中已通过 database.AppendMigrate 注册表名,
|
||||
// 这样聚合启动时也能为它们建表(连接参数沿用 SDK 默认值)。
|
||||
DBService = with.Databases(config.Spec.Databases, &types.SqlOptions{
|
||||
MaxIdleConns: vars.SqlOptionMaxIdleConns,
|
||||
MaxOpenConns: vars.SqlOptionMaxOpenConns,
|
||||
ConnMaxLifetime: vars.SqlOptionConnMaxLifetime,
|
||||
IsAutoMigrate: true,
|
||||
Debug: vars.SqlOptionDebug,
|
||||
})
|
||||
|
||||
// 初始化Etcd客户端,用于服务发现和配置管理
|
||||
EtcdService = with.Etcd(config.Spec.Etcd)
|
||||
|
||||
@@ -35,12 +35,24 @@ func New(key string, expireSeconds int64, anonymous []string) (*Server, error) {
|
||||
engine.Use(gin.Logger(), gin.Recovery())
|
||||
return &Server{
|
||||
GRPC: grpcServer,
|
||||
Gateway: gwRuntime.NewServeMux(),
|
||||
Gateway: gwRuntime.NewServeMux(gwRuntime.WithRoutingErrorHandler(routeMissHandler)),
|
||||
HTTP: engine,
|
||||
auth: auth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// routeMissHandler 是 grpc-gateway 的路由错误处理器:只有"路由未命中"才会被标记,
|
||||
// 随后仍按默认行为写出错误体。业务 handler 返回的 NotFound 走的是 errorHandler,
|
||||
// 不会被标记,因而其响应能原样透出而不会被 Gin 的纯文本 404 覆盖。
|
||||
func routeMissHandler(ctx context.Context, mux *gwRuntime.ServeMux, marshaler gwRuntime.Marshaler, w http.ResponseWriter, r *http.Request, httpStatus int) {
|
||||
if httpStatus == http.StatusNotFound {
|
||||
if recorder, ok := w.(routingMissRecorder); ok {
|
||||
recorder.markRoutingMiss()
|
||||
}
|
||||
}
|
||||
gwRuntime.DefaultRoutingErrorHandler(ctx, mux, marshaler, w, r, httpStatus)
|
||||
}
|
||||
|
||||
func (s *Server) Start(grpcAddr, httpAddr string) error {
|
||||
grpcListener, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
@@ -60,18 +72,9 @@ func (s *Server) Start(grpcAddr, httpAddr string) error {
|
||||
}
|
||||
s.HTTP.POST("/rpc/:module/:service/:method", s.dynamic.handle)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recorder := newBufferedResponse()
|
||||
s.Gateway.ServeHTTP(recorder, r)
|
||||
if recorder.status != http.StatusNotFound {
|
||||
recorder.flush(w)
|
||||
return
|
||||
}
|
||||
s.HTTP.ServeHTTP(w, r)
|
||||
})
|
||||
s.http = &http.Server{
|
||||
Addr: httpAddr,
|
||||
Handler: s.auth.httpMiddleware(handler),
|
||||
Handler: s.auth.httpMiddleware(s.handler()),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
@@ -89,6 +92,21 @@ func (s *Server) Start(grpcAddr, httpAddr string) error {
|
||||
return serveErr
|
||||
}
|
||||
|
||||
// handler 先让 grpc-gateway 处理请求,仅当网关确实未命中路由时才回退到 Gin
|
||||
// (动态 RPC 与原生的 /rest 路由都注册在 Gin 上)。业务返回的 404/NotFound
|
||||
// 属于网关的正常响应,必须原样透出。
|
||||
func (s *Server) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recorder := newBufferedResponse()
|
||||
s.Gateway.ServeHTTP(recorder, r)
|
||||
if !recorder.routingMiss {
|
||||
recorder.flush(w)
|
||||
return
|
||||
}
|
||||
s.HTTP.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
@@ -109,10 +127,17 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
return s.http.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// routingMissRecorder 由 bufferedResponse 实现,用于区分"网关未命中路由"与业务 NotFound。
|
||||
type routingMissRecorder interface {
|
||||
markRoutingMiss()
|
||||
}
|
||||
|
||||
type bufferedResponse struct {
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
// routingMiss 表示该 404 来自 grpc-gateway 的路由错误处理,而非业务响应。
|
||||
routingMiss bool
|
||||
}
|
||||
|
||||
func newBufferedResponse() *bufferedResponse {
|
||||
@@ -122,6 +147,7 @@ func newBufferedResponse() *bufferedResponse {
|
||||
func (r *bufferedResponse) Header() http.Header { return r.header }
|
||||
func (r *bufferedResponse) WriteHeader(status int) { r.status = status }
|
||||
func (r *bufferedResponse) Write(data []byte) (int, error) { return r.body.Write(data) }
|
||||
func (r *bufferedResponse) markRoutingMiss() { r.routingMiss = true }
|
||||
func (r *bufferedResponse) flush(w http.ResponseWriter) {
|
||||
for key, values := range r.header {
|
||||
for _, value := range values {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,3 +23,38 @@ func TestHTTPRouterIsAvailable(t *testing.T) {
|
||||
t.Fatalf("unexpected status: %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayDispatchKeepsBusinessNotFound 守住 404 分流:网关未命中路由才回退 Gin,
|
||||
// 业务 handler 返回的 404 必须原样透出,不能被 Gin 的纯文本 404 覆盖。
|
||||
func TestGatewayDispatchKeepsBusinessNotFound(t *testing.T) {
|
||||
srv, err := New("0123456789abcdef0123456789abcdef", 3600, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.Gateway.HandlePath(http.MethodPost, "/demo.Thing/Get", func(w http.ResponseWriter, _ *http.Request, _ map[string]string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"code":5,"message":"record not found"}`))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.HTTP.POST("/rest/demo/ping", func(c *gin.Context) { c.String(http.StatusOK, "gin") })
|
||||
|
||||
handler := srv.handler()
|
||||
|
||||
t.Run("business not found", func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/demo.Thing/Get", nil))
|
||||
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "record not found") {
|
||||
t.Fatalf("business 404 was overwritten: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gateway route miss", func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/rest/demo/ping", nil))
|
||||
if response.Code != http.StatusOK || response.Body.String() != "gin" {
|
||||
t.Fatalf("gin fallback failed: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user