Просмотр исходного кода

fix(user-migration): 补齐主节点保护与导入校验

- 为 user-migrations 管理路由增加仅 master 节点可访问的保护
- 为导入创建用户补充用户名、显示名、邮箱长度校验及测试
- 调整测试环境部署脚本,自动写入 CN/OV 的 NODE_TYPE

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 2 дней назад
Родитель
Сommit
2f7a71d61e
6 измененных файлов: 118 добавлений и 16 удалений
  1. +14
    -0
      middleware/auth.go
  2. +36
    -0
      model/region_migration_user_test.go
  3. +23
    -0
      model/user.go
  4. +1
    -1
      router/api-router.go
  5. +28
    -2
      router/user_migration_router_test.go
  6. +16
    -13
      scripts/deploy-test.sh

+ 14
- 0
middleware/auth.go Просмотреть файл

@@ -175,6 +175,20 @@ func RootAuth() func(c *gin.Context) {
} }
} }


func MasterNodeOnly() func(c *gin.Context) {
return func(c *gin.Context) {
if !common.IsMasterNode {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "only master node can access this endpoint",
})
c.Abort()
return
}
c.Next()
}
}

func WssAuth(c *gin.Context) { func WssAuth(c *gin.Context) {


} }


+ 36
- 0
model/region_migration_user_test.go Просмотреть файл

@@ -74,6 +74,42 @@ func TestInsertImportedUserWithHashedPassword_RejectsExplicitId(t *testing.T) {
require.Contains(t, err.Error(), "Id must not be set") require.Contains(t, err.Error(), "Id must not be set")
} }


func TestInsertImportedUserWithHashedPassword_RejectsOverlongFields(t *testing.T) {
db := setupRegionMigrationUserDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

user := &User{
Username: "abcdefghijklmnopqrstu",
Password: "already-hashed-value",
Email: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com",
DisplayName: "DDDDDDDDDDDDDDDDDDDDDDDDD",
Status: common.UserStatusEnabled,
Role: common.RoleCommonUser,
Group: "default",
Quota: 123,
AffCode: "A002",
CreatedAt: 1710000000,
}

err := InsertImportedUserWithHashedPassword(user)
require.Error(t, err)
require.Contains(t, err.Error(), "username exceeds max length")

user.Username = "valid-user"
err = InsertImportedUserWithHashedPassword(user)
require.Error(t, err)
require.Contains(t, err.Error(), "display_name exceeds max length")

user.DisplayName = "Valid Name"
err = InsertImportedUserWithHashedPassword(user)
require.Error(t, err)
require.Contains(t, err.Error(), "email exceeds max length")
}

func TestCopyOAuthBindingsIfMissing_Idempotent(t *testing.T) { func TestCopyOAuthBindingsIfMissing_Idempotent(t *testing.T) {
db := setupRegionMigrationUserDB(t) db := setupRegionMigrationUserDB(t)
orig := DB orig := DB


+ 23
- 0
model/user.go Просмотреть файл

@@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"


"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
@@ -579,6 +580,25 @@ func InsertImportedUserWithHashedPassword(user *User) error {
return InsertImportedUserWithHashedPasswordTx(DB, user) return InsertImportedUserWithHashedPasswordTx(DB, user)
} }


func validateImportedUserFields(user *User) error {
if user == nil {
return errors.New("user is nil")
}
if strings.TrimSpace(user.Username) == "" {
return errors.New("username is required")
}
if utf8.RuneCountInString(user.Username) > UserNameMaxLength {
return fmt.Errorf("username exceeds max length %d", UserNameMaxLength)
}
if utf8.RuneCountInString(user.DisplayName) > 20 {
return errors.New("display_name exceeds max length 20")
}
if utf8.RuneCountInString(user.Email) > 50 {
return errors.New("email exceeds max length 50")
}
return nil
}

func InsertImportedUserWithHashedPasswordTx(tx *gorm.DB, user *User) error { func InsertImportedUserWithHashedPasswordTx(tx *gorm.DB, user *User) error {
if user == nil { if user == nil {
return errors.New("user is nil") return errors.New("user is nil")
@@ -586,6 +606,9 @@ func InsertImportedUserWithHashedPasswordTx(tx *gorm.DB, user *User) error {
if user.Id != 0 { if user.Id != 0 {
return errors.New("InsertImportedUserWithHashedPassword: Id must not be set, let DB auto-assign") return errors.New("InsertImportedUserWithHashedPassword: Id must not be set, let DB auto-assign")
} }
if err := validateImportedUserFields(user); err != nil {
return err
}
if user.AffCode == "" { if user.AffCode == "" {
user.AffCode = common.GetRandomString(4) user.AffCode = common.GetRandomString(4)
} }


+ 1
- 1
router/api-router.go Просмотреть файл

@@ -417,7 +417,7 @@ func SetApiRouter(router *gin.Engine) {
} }


userMigrationRoute := apiRouter.Group("/user-migrations") userMigrationRoute := apiRouter.Group("/user-migrations")
userMigrationRoute.Use(middleware.RootAuth())
userMigrationRoute.Use(middleware.RootAuth(), middleware.MasterNodeOnly())
{ {
userMigrationRoute.GET("/candidate-users", controller.ListMigrationCandidateUsers) userMigrationRoute.GET("/candidate-users", controller.ListMigrationCandidateUsers)
userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch) userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch)


+ 28
- 2
router/user_migration_router_test.go Просмотреть файл

@@ -61,7 +61,7 @@ func setupUserMigrationRootRouter() *gin.Engine {
session.Save() session.Save()
c.Request.Header.Set("New-Api-User", "1") c.Request.Header.Set("New-Api-User", "1")
c.Next() c.Next()
}, middleware.RootAuth())
}, middleware.RootAuth(), middleware.MasterNodeOnly())
{ {
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
@@ -93,7 +93,7 @@ func setupUserMigrationAdminRouter() *gin.Engine {
session.Save() session.Save()
c.Request.Header.Set("New-Api-User", "2") c.Request.Header.Set("New-Api-User", "2")
c.Next() c.Next()
}, middleware.RootAuth())
}, middleware.RootAuth(), middleware.MasterNodeOnly())
{ {
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
@@ -106,10 +106,13 @@ func setupUserMigrationAdminRouter() *gin.Engine {
func TestUserMigrationRoutes_RootCanAccess(t *testing.T) { func TestUserMigrationRoutes_RootCanAccess(t *testing.T) {
db := setupUserMigrationRouterDB(t) db := setupUserMigrationRouterDB(t)
orig := model.DB orig := model.DB
origMaster := common.IsMasterNode
model.DB = db model.DB = db
defer func() { defer func() {
model.DB = orig model.DB = orig
common.IsMasterNode = origMaster
}() }()
common.IsMasterNode = true


require.NoError(t, db.Create(&model.UserMigrationBatch{ require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1, Id: 1,
@@ -141,10 +144,13 @@ func TestUserMigrationRoutes_RootCanAccess(t *testing.T) {
func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { func TestUserMigrationRoutes_AdminForbidden(t *testing.T) {
db := setupUserMigrationRouterDB(t) db := setupUserMigrationRouterDB(t)
orig := model.DB orig := model.DB
origMaster := common.IsMasterNode
model.DB = db model.DB = db
defer func() { defer func() {
model.DB = orig model.DB = orig
common.IsMasterNode = origMaster
}() }()
common.IsMasterNode = true


router := setupUserMigrationAdminRouter() router := setupUserMigrationAdminRouter()


@@ -166,3 +172,23 @@ func TestUserMigrationRoutes_AdminForbidden(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "success\":false") require.Contains(t, w.Body.String(), "success\":false")
} }

func TestUserMigrationRoutes_RootForbiddenOnSlaveNode(t *testing.T) {
db := setupUserMigrationRouterDB(t)
orig := model.DB
origMaster := common.IsMasterNode
model.DB = db
defer func() {
model.DB = orig
common.IsMasterNode = origMaster
}()
common.IsMasterNode = false

router := setupUserMigrationRootRouter()

req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusForbidden, w.Code)
require.Contains(t, w.Body.String(), "only master node")
}

+ 16
- 13
scripts/deploy-test.sh Просмотреть файл

@@ -10,15 +10,12 @@


set -euo pipefail set -euo pipefail


# ==================== 配置 ====================
DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api" DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api"
PROJECT_DIR="/mnt/d/code/new-api" PROJECT_DIR="/mnt/d/code/new-api"


# 测试节点
CN_DIR="/root/new-api-cn" CN_DIR="/root/new-api-cn"
OV_DIR="/root/new-api-ov" OV_DIR="/root/new-api-ov"


# ==================== 颜色 ====================
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
@@ -38,14 +35,13 @@ section() {
echo -e " ${BOLD}=========================================${NC}" echo -e " ${BOLD}=========================================${NC}"
} }


# ==================== 参数解析 ====================
TARGET="all" TARGET="all"
NO_BUILD=false NO_BUILD=false


for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
cn) TARGET="cn" ;;
ov) TARGET="ov" ;;
cn) TARGET="cn" ;;
ov) TARGET="ov" ;;
--no-build) NO_BUILD=true ;; --no-build) NO_BUILD=true ;;
-h|--help) -h|--help)
echo "用法: $0 [cn|ov] [--no-build]" echo "用法: $0 [cn|ov] [--no-build]"
@@ -62,7 +58,6 @@ for arg in "$@"; do
esac esac
done done


# ==================== Step 1: 构建并推送 ====================
if [ "$NO_BUILD" = false ]; then if [ "$NO_BUILD" = false ]; then
section "Step 1/2: 构建并推送镜像" section "Step 1/2: 构建并推送镜像"


@@ -99,7 +94,6 @@ else
ok "使用镜像: ${IMAGE}" ok "使用镜像: ${IMAGE}"
fi fi


# ==================== Step 2: 部署到测试节点 ====================
section "Step 2/2: 部署到测试环境" section "Step 2/2: 部署到测试环境"


FAILED=() FAILED=()
@@ -107,6 +101,7 @@ FAILED=()
deploy_local() { deploy_local() {
local NAME="$1" local NAME="$1"
local DIR="$2" local DIR="$2"
local NODE_TYPE_VALUE="$3"


echo "" echo ""
info "部署到 ${NAME}..." info "部署到 ${NAME}..."
@@ -123,6 +118,11 @@ deploy_local() {


echo " [1/3] 更新 image tag..." echo " [1/3] 更新 image tag..."
sed -i "s|${DOCKER_REGISTRY}:.*|${DOCKER_REGISTRY}:${TAG}|" docker-compose.yml sed -i "s|${DOCKER_REGISTRY}:.*|${DOCKER_REGISTRY}:${TAG}|" docker-compose.yml
if grep -q "NODE_TYPE=" docker-compose.yml; then
sed -i "s|NODE_TYPE=.*|NODE_TYPE=${NODE_TYPE_VALUE}|" docker-compose.yml
else
sed -i "/SESSION_NAME=/a\\ - NODE_TYPE=${NODE_TYPE_VALUE}" docker-compose.yml
fi


echo " [2/3] 拉取新镜像..." echo " [2/3] 拉取新镜像..."
docker compose pull new-api docker compose pull new-api
@@ -136,15 +136,18 @@ deploy_local() {
} }


case "$TARGET" in case "$TARGET" in
cn) deploy_local "国内测试(CN)" "$CN_DIR" ;;
ov) deploy_local "国外测试(OV)" "$OV_DIR" ;;
cn)
deploy_local "国内测试(CN)" "$CN_DIR" "master"
;;
ov)
deploy_local "国外测试(OV)" "$OV_DIR" "slave"
;;
all) all)
deploy_local "国内测试(CN)" "$CN_DIR"
deploy_local "国外测试(OV)" "$OV_DIR"
deploy_local "国内测试(CN)" "$CN_DIR" "master"
deploy_local "国外测试(OV)" "$OV_DIR" "slave"
;; ;;
esac esac


# ==================== 总结 ====================
echo "" echo ""
section "部署总结" section "部署总结"
echo -e " 镜像: ${GREEN}${IMAGE}${NC}" echo -e " 镜像: ${GREEN}${IMAGE}${NC}"


Загрузка…
Отмена
Сохранить