fix version 1

This commit is contained in:
2026-09-22 21:15:34 +08:00
parent 9f86366638
commit d63d7e8b3a
277 changed files with 9959 additions and 1514 deletions

View File

@@ -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 字节,能通过长度校验);
// - Cblocksmesh2022Cbsm-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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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())
}
})
}