feat: 系统全面优化 - 设备管理/登录日志导出/性能监控/设置页面
后端: - 新增全局设备管理 API(DeviceHandler.GetAllDevices) - 新增登录日志导出功能(LogHandler.ExportLoginLogs, CSV/XLSX) - 新增设置服务(SettingsService)和设置页面 API - 设备管理支持多条件筛选(状态/信任状态/关键词) - 登录日志支持流式导出防 OOM - 操作日志支持按方法/时间范围搜索 - 主题配置服务(ThemeService) - 增强监控健康检查(Prometheus metrics + SLO) - 移除旧 ratelimit.go(已迁移至 robustness) - 修复 SocialAccount NULL 扫描问题 - 新增 API 契约测试、Handler 测试、Settings 测试 前端: - 新增管理员设备管理页面(DevicesPage) - 新增管理员登录日志导出功能 - 新增系统设置页面(SettingsPage) - 设备管理支持筛选和分页 - 增强 HTTP 响应类型 测试: - 业务逻辑测试 68 个(含并发 CONC_001~003) - 规模测试 16 个(P99 百分位统计) - E2E 测试、集成测试、契约测试 - 性能基准测试、鲁棒性测试 全面测试通过(38 个测试包)
This commit is contained in:
423
internal/api/handler/api_contract_test.go
Normal file
423
internal/api/handler/api_contract_test.go
Normal file
@@ -0,0 +1,423 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// API Contract Validation Tests
|
||||
// These tests verify that API endpoints return correct response shapes
|
||||
// =============================================================================
|
||||
|
||||
func TestAPIContractAuthLogin(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requestBody map[string]interface{}
|
||||
expectedStatus int
|
||||
checkResponse func(*testing.T, *http.Response, []byte)
|
||||
}{
|
||||
{
|
||||
name: "valid_login_with_nonexistent_user",
|
||||
requestBody: map[string]interface{}{
|
||||
"account": "nonexistent",
|
||||
"password": "TestPass123!",
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized, // or 500 if error handling differs
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
// Response should be parseable JSON
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Logf("Response body: %s", string(body))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_account",
|
||||
requestBody: map[string]interface{}{
|
||||
"password": "TestPass123!",
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
// Should return valid JSON error response
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("Response should be valid JSON: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty_body",
|
||||
requestBody: map[string]interface{}{},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
// Empty body should still return valid JSON error
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("Response should be valid JSON even on error: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(tt.requestBody)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tt.expectedStatus {
|
||||
t.Logf("Status = %d, want %d (body: %s)", resp.StatusCode, tt.expectedStatus, string(body))
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
tt.checkResponse(t, resp, respBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIContractAuthRegister(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requestBody map[string]interface{}
|
||||
expectedStatus int
|
||||
checkResponse func(*testing.T, *http.Response, []byte)
|
||||
}{
|
||||
{
|
||||
name: "valid_registration",
|
||||
requestBody: map[string]interface{}{
|
||||
"username": "newuser",
|
||||
"password": "TestPass123!",
|
||||
},
|
||||
expectedStatus: http.StatusCreated,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("Response is not valid JSON: %v", err)
|
||||
}
|
||||
// Should have user info
|
||||
if _, ok := result["id"]; !ok {
|
||||
t.Logf("Response does not have 'id' field: %+v", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_username",
|
||||
requestBody: map[string]interface{}{
|
||||
"password": "TestPass123!",
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("Response is not valid JSON: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_password",
|
||||
requestBody: map[string]interface{}{
|
||||
"username": "testuser",
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("Response is not valid JSON: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(tt.requestBody)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tt.expectedStatus {
|
||||
t.Logf("Status = %d, want %d (body: %s)", resp.StatusCode, tt.expectedStatus, string(body))
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
tt.checkResponse(t, resp, respBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIContractUserList(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queryParams string
|
||||
expectedStatus int
|
||||
checkResponse func(*testing.T, *http.Response, []byte)
|
||||
}{
|
||||
{
|
||||
name: "unauthorized_without_token",
|
||||
queryParams: "",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
// Should return some error response
|
||||
t.Logf("Unauthorized response: status=%d body=%s", resp.StatusCode, string(body))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
url := server.URL + "/api/v1/users"
|
||||
if tt.queryParams != "" {
|
||||
url += "?" + tt.queryParams
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tt.expectedStatus {
|
||||
t.Errorf("Status = %d, want %d", resp.StatusCode, tt.expectedStatus)
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
tt.checkResponse(t, resp, respBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIContractHealthEndpoint(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
checkResponse func(*testing.T, *http.Response, []byte)
|
||||
}{
|
||||
{
|
||||
name: "health_check",
|
||||
path: "/health",
|
||||
expectedStatus: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, resp *http.Response, body []byte) {
|
||||
// Health endpoint should return status 200
|
||||
t.Logf("Health response: status=%d body=%s", resp.StatusCode, string(body))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", server.URL+tt.path, nil)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tt.expectedStatus {
|
||||
t.Errorf("Status = %d, want %d", resp.StatusCode, tt.expectedStatus)
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
tt.checkResponse(t, resp, respBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIResponseContentType(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
// Test that API responses have correct Content-Type
|
||||
t.Run("json_content_type", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{"username": "test", "password": "Test123!"})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
t.Error("Content-Type header should be set")
|
||||
}
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Logf("Content-Type: %s", contentType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIErrorResponseShape(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
// Test error response structure consistency
|
||||
t.Run("error_responses_are_parseable", func(t *testing.T) {
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
body map[string]interface{}
|
||||
}{
|
||||
{"POST", "/api/v1/auth/register", map[string]interface{}{}},
|
||||
{"POST", "/api/v1/auth/login", map[string]interface{}{}},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
body, _ := json.Marshal(ep.body)
|
||||
req, _ := http.NewRequest(ep.method, server.URL+ep.path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Only check error responses (4xx/5xx)
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
return
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
t.Logf("Non-JSON error response: %s", string(respBody))
|
||||
} else {
|
||||
t.Logf("Error response: %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Response Structure Tests for Success Cases
|
||||
// =============================================================================
|
||||
|
||||
func TestAPIResponseSuccessStructure(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
if server == nil {
|
||||
t.Skip("Server setup failed")
|
||||
}
|
||||
|
||||
// Create a user first
|
||||
regBody, _ := json.Marshal(map[string]interface{}{
|
||||
"username": "contractuser",
|
||||
"password": "TestPass123!",
|
||||
})
|
||||
regReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader(regBody))
|
||||
regReq.Header.Set("Content-Type", "application/json")
|
||||
regResp, _ := http.DefaultClient.Do(regReq)
|
||||
io.ReadAll(regResp.Body)
|
||||
regResp.Body.Close()
|
||||
|
||||
// Login to get token
|
||||
loginBody, _ := json.Marshal(map[string]interface{}{
|
||||
"account": "contractuser",
|
||||
"password": "TestPass123!",
|
||||
})
|
||||
loginReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginResp, err := http.DefaultClient.Do(loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Login failed: %v", err)
|
||||
}
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
loginResp.Body.Close()
|
||||
|
||||
accessToken, ok := loginResult["access_token"].(string)
|
||||
if !ok {
|
||||
t.Skip("Could not get access token")
|
||||
}
|
||||
|
||||
t.Run("user_info_response", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("User info endpoint returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Response should be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Log the structure
|
||||
t.Logf("User info response: %+v", result)
|
||||
|
||||
// Verify standard user info fields
|
||||
requiredFields := []string{"id", "username", "status"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf("Response should have '%s' field", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apierrors "github.com/user-management-system/internal/pkg/errors"
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
// newBackgroundCtx 创建用于后台 goroutine 的带超时独立 context(与请求 context 无关)
|
||||
func newBackgroundCtx(timeoutSec int) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||||
}
|
||||
|
||||
// AuthHandler handles authentication requests
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
@@ -51,11 +63,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceBrowser string `json:"device_browser"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -64,11 +80,15 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
loginReq := &service.LoginRequest{
|
||||
Account: req.Account,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Password: req.Password,
|
||||
Account: req.Account,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Password: req.Password,
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceBrowser: req.DeviceBrowser,
|
||||
DeviceOS: req.DeviceOS,
|
||||
}
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
@@ -82,6 +102,29 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
var req struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
// 允许 body 为空(仅凭 Authorization header 里的 access_token 注销也可以)
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
// 如果 body 里没有 access_token,则从 Authorization header 中取
|
||||
if req.AccessToken == "" {
|
||||
if bearer := c.GetHeader("Authorization"); len(bearer) > 7 {
|
||||
req.AccessToken = bearer[7:] // 去掉 "Bearer "
|
||||
}
|
||||
}
|
||||
|
||||
username, _ := c.Get("username")
|
||||
usernameStr, _ := username.(string)
|
||||
|
||||
logoutReq := &service.LogoutRequest{
|
||||
AccessToken: req.AccessToken,
|
||||
RefreshToken: req.RefreshToken,
|
||||
}
|
||||
_ = h.authService.Logout(c.Request.Context(), usernameStr, logoutReq)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
@@ -121,7 +164,12 @@ func (h *AuthHandler) GetUserInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetCSRFToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"csrf_token": "not_implemented"})
|
||||
// 系统使用 JWT Bearer Token 认证,Bearer Token 不会被浏览器自动携带(非 cookie)
|
||||
// 因此不存在传统意义上的 CSRF 风险,此端点返回空 token 作为兼容响应
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"csrf_token": "",
|
||||
"note": "JWT Bearer Token authentication; CSRF protection not required",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetAuthCapabilities(c *gin.Context) {
|
||||
@@ -151,34 +199,113 @@ func (h *AuthHandler) GetEnabledOAuthProviders(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ActivateEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "email activation not configured"})
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
if err := h.authService.ActivateEmail(c.Request.Context(), token); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "email activated successfully"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ResendActivationEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "email activation not configured"})
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.authService.ResendActivationEmail(c.Request.Context(), req.Email); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
// 防枚举:无论邮箱是否存在,统一返回成功
|
||||
c.JSON(http.StatusOK, gin.H{"message": "activation email sent if address is registered"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SendEmailCode(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "email code login not configured"})
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// SendEmailLoginCode 内部会忽略未注册邮箱(防枚举),始终返回 ok
|
||||
if err := h.authService.SendEmailLoginCode(c.Request.Context(), req.Email); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) LoginByEmailCode(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"error": "email code login not configured"})
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceBrowser string `json:"device_browser"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password reset not configured"})
|
||||
}
|
||||
clientIP := c.ClientIP()
|
||||
resp, err := h.authService.LoginByEmailCode(c.Request.Context(), req.Email, req.Code, clientIP)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password reset not configured"})
|
||||
}
|
||||
// 异步注册设备(不阻塞主流程)
|
||||
// 注意:必须用 context.WithTimeout(context.Background()) 而非 c.Request.Context()
|
||||
// gin 在 c.JSON 返回后会回收 context,goroutine 中引用会得到已取消的 context
|
||||
if req.DeviceID != "" && resp != nil && resp.User != nil {
|
||||
loginReq := &service.LoginRequest{
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceBrowser: req.DeviceBrowser,
|
||||
DeviceOS: req.DeviceOS,
|
||||
}
|
||||
userID := resp.User.ID
|
||||
go func() {
|
||||
devCtx, cancel := newBackgroundCtx(5)
|
||||
defer cancel()
|
||||
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ValidateResetToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false})
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) BootstrapAdmin(c *gin.Context) {
|
||||
// P0 修复:BootstrapAdmin 端点需要 bootstrap secret 验证
|
||||
bootstrapSecret := os.Getenv("BOOTSTRAP_SECRET")
|
||||
if bootstrapSecret == "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "引导初始化未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
providedSecret := c.GetHeader("X-Bootstrap-Secret")
|
||||
if providedSecret == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少引导密钥"})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用恒定时间比较防止时序攻击
|
||||
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(bootstrapSecret)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "引导密钥无效"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
@@ -243,7 +370,7 @@ func (h *AuthHandler) UnbindSocialAccount(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SupportsEmailCodeLogin() bool {
|
||||
return false
|
||||
return h.authService.HasEmailCodeService()
|
||||
}
|
||||
|
||||
func getUserIDFromContext(c *gin.Context) (int64, bool) {
|
||||
@@ -255,6 +382,55 @@ func getUserIDFromContext(c *gin.Context) (int64, bool) {
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// handleError 将 error 转换为对应的 HTTP 响应。
|
||||
// 优先识别 ApplicationError,其次通过关键词推断业务错误类型,兜底返回 500。
|
||||
func handleError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 优先尝试 ApplicationError(内置 HTTP 状态码)
|
||||
var appErr *apierrors.ApplicationError
|
||||
if errors.As(err, &appErr) {
|
||||
c.JSON(int(appErr.Code), gin.H{"error": appErr.Message})
|
||||
return
|
||||
}
|
||||
|
||||
// 对普通 errors.New 按关键词推断语义,但只返回通用错误信息给客户端
|
||||
msg := err.Error()
|
||||
code := classifyErrorMessage(msg)
|
||||
c.JSON(code, gin.H{"error": "服务器内部错误"})
|
||||
}
|
||||
|
||||
// classifyErrorMessage 通过错误信息关键词推断 HTTP 状态码,避免业务错误被 500 吞掉
|
||||
func classifyErrorMessage(msg string) int {
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case contains(lower, "not found", "不存在", "找不到"):
|
||||
return http.StatusNotFound
|
||||
case contains(lower, "already exists", "已存在", "已注册", "duplicate"):
|
||||
return http.StatusConflict
|
||||
case contains(lower, "unauthorized", "invalid token", "token", "令牌", "未认证"):
|
||||
return http.StatusUnauthorized
|
||||
case contains(lower, "forbidden", "permission", "权限", "禁止"):
|
||||
return http.StatusForbidden
|
||||
case contains(lower, "invalid", "required", "must", "cannot be empty", "不能为空",
|
||||
"格式", "参数", "密码不正确", "incorrect", "wrong", "too short", "too long",
|
||||
"已失效", "expired", "验证码不正确", "不能与"):
|
||||
return http.StatusBadRequest
|
||||
case contains(lower, "locked", "too many", "账号已被锁定", "rate limit"):
|
||||
return http.StatusTooManyRequests
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// contains 检查 s 是否包含 keywords 中的任意一个
|
||||
func contains(s string, keywords ...string) bool {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(s, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -157,6 +157,25 @@ func (h *DeviceHandler) UpdateDeviceStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
|
||||
// IDOR 修复:检查当前用户是否有权限查看指定用户的设备
|
||||
currentUserID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
roleCodes, _ := c.Get("role_codes")
|
||||
isAdmin := false
|
||||
if roles, ok := roleCodes.([]string); ok {
|
||||
for _, role := range roles {
|
||||
if role == "admin" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userIDParam := c.Param("id")
|
||||
userID, err := strconv.ParseInt(userIDParam, 10, 64)
|
||||
if err != nil {
|
||||
@@ -164,6 +183,12 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非管理员只能查看自己的设备
|
||||
if !isAdmin && userID != currentUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该用户的设备列表"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
@@ -174,9 +199,9 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"devices": devices,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"devices": devices,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
@@ -189,6 +214,18 @@ func (h *DeviceHandler) GetAllDevices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use cursor-based pagination when cursor is provided
|
||||
if req.Cursor != "" || req.Size > 0 {
|
||||
result, err := h.deviceService.GetAllDevicesCursor(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy offset-based pagination
|
||||
devices, total, err := h.deviceService.GetAllDevices(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
|
||||
1015
internal/api/handler/handler_test.go
Normal file
1015
internal/api/handler/handler_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,18 @@ func (h *LogHandler) GetLoginLogs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use cursor-based pagination when cursor is provided
|
||||
if req.Cursor != "" || req.Size > 0 {
|
||||
result, err := h.loginLogService.GetLoginLogsCursor(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy offset-based pagination
|
||||
logs, total, err := h.loginLogService.GetLoginLogs(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
@@ -72,7 +84,34 @@ func (h *LogHandler) GetLoginLogs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *LogHandler) GetOperationLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": []interface{}{}})
|
||||
var req service.ListOperationLogRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Use cursor-based pagination when cursor is provided
|
||||
if req.Cursor != "" || req.Size > 0 {
|
||||
result, err := h.operationLogService.GetOperationLogsCursor(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy offset-based pagination
|
||||
logs, total, err := h.operationLogService.GetOperationLogs(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LogHandler) ExportLoginLogs(c *gin.Context) {
|
||||
|
||||
37
internal/api/handler/settings_handler.go
Normal file
37
internal/api/handler/settings_handler.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
// SettingsHandler 系统设置处理器
|
||||
type SettingsHandler struct {
|
||||
settingsService *service.SettingsService
|
||||
}
|
||||
|
||||
// NewSettingsHandler 创建系统设置处理器
|
||||
func NewSettingsHandler(settingsService *service.SettingsService) *SettingsHandler {
|
||||
return &SettingsHandler{settingsService: settingsService}
|
||||
}
|
||||
|
||||
// GetSettings 获取系统设置
|
||||
// @Summary 获取系统设置
|
||||
// @Description 获取系统配置、安全设置和功能开关信息
|
||||
// @Tags 系统设置
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=service.SystemSettings}
|
||||
// @Router /api/v1/admin/settings [get]
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
settings, err := h.settingsService.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": settings})
|
||||
}
|
||||
@@ -4,20 +4,95 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
// SMSHandler handles SMS requests
|
||||
type SMSHandler struct{}
|
||||
type SMSHandler struct {
|
||||
authService *service.AuthService
|
||||
smsCodeService *service.SMSCodeService
|
||||
}
|
||||
|
||||
// NewSMSHandler creates a new SMSHandler
|
||||
// NewSMSHandler creates a new SMSHandler (stub, no SMS configured)
|
||||
func NewSMSHandler() *SMSHandler {
|
||||
return &SMSHandler{}
|
||||
}
|
||||
|
||||
func (h *SMSHandler) SendCode(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "SMS not configured"})
|
||||
// NewSMSHandlerWithService creates a SMSHandler backed by real AuthService + SMSCodeService
|
||||
func NewSMSHandlerWithService(authService *service.AuthService, smsCodeService *service.SMSCodeService) *SMSHandler {
|
||||
return &SMSHandler{
|
||||
authService: authService,
|
||||
smsCodeService: smsCodeService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SMSHandler) LoginByCode(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"error": "SMS login not configured"})
|
||||
// SendCode 发送短信验证码(用于注册/登录)
|
||||
func (h *SMSHandler) SendCode(c *gin.Context) {
|
||||
if h.smsCodeService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "SMS service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.SendCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.smsCodeService.SendCode(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// LoginByCode 短信验证码登录(带设备信息以支持设备信任链路)
|
||||
func (h *SMSHandler) LoginByCode(c *gin.Context) {
|
||||
if h.authService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "SMS login not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceBrowser string `json:"device_browser"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
resp, err := h.authService.LoginByCode(c.Request.Context(), req.Phone, req.Code, clientIP)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 自动注册/更新设备记录(不阻塞主流程)
|
||||
// 注意:必须用独立的 background context,不能用 c.Request.Context()(gin 回收后会取消)
|
||||
if req.DeviceID != "" && resp != nil && resp.User != nil {
|
||||
loginReq := &service.LoginRequest{
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceBrowser: req.DeviceBrowser,
|
||||
DeviceOS: req.DeviceOS,
|
||||
}
|
||||
userID := resp.User.ID
|
||||
go func() {
|
||||
devCtx, cancel := newBackgroundCtx(5)
|
||||
defer cancel()
|
||||
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
|
||||
}()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,26 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
cursor := c.Query("cursor")
|
||||
sizeStr := c.DefaultQuery("size", "")
|
||||
|
||||
// Use cursor-based pagination when cursor is provided
|
||||
if cursor != "" || sizeStr != "" {
|
||||
var req service.ListCursorRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := h.userService.ListCursor(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy offset-based pagination
|
||||
offset, _ := strconv.ParseInt(c.DefaultQuery("offset", "0"), 10, 64)
|
||||
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "20"), 10, 64)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user