package handler import ( "context" "encoding/json" "errors" "io" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model" "git.sundynix.cn/Blizzard/Veyra/internal/service" ) type LoginLimiter interface { Login(context.Context, string, string) (bool, error) } type IdentityOptions struct { Market *service.MarketService Backtests *service.BacktestService Auth *service.AuthService Providers *service.ProviderService Candidates *service.CandidateService Plans *service.TradePlanService Fills *service.TradeFillService Notifications *service.NotificationService Strategies *service.StrategyService Accounts *service.AccountService Limiter LoginLimiter DevelopmentLogin bool SecureCookie bool } const sessionCookie = "veyra_admin_session" func bindIdentity(r *gin.Engine, options IdentityOptions) { r.SetTrustedProxies(nil) adminAuth := func(c *gin.Context) { authenticate(c, options, "admin") } appAuth := func(c *gin.Context) { authenticate(c, options, "app") } login := func(audience string) gin.HandlerFunc { return func(c *gin.Context) { if !options.DevelopmentLogin { fail(c, apperror.ErrUnavailable) return } if audience == "admin" && !sameOrigin(c.Request) { fail(c, apperror.ErrForbidden) return } var input struct { LoginName string `json:"login_name"` Password string `json:"password"` } if !decode(c, &input) { return } if len(input.LoginName) > 64 || len(input.Password) > 72 { fail(c, apperror.ErrUnauthenticated) return } if options.Auth == nil || options.Limiter == nil { fail(c, apperror.ErrUnavailable) return } allowed, err := options.Limiter.Login(c.Request.Context(), c.ClientIP(), strings.ToLower(strings.TrimSpace(input.LoginName))) if err != nil { fail(c, apperror.ErrUnavailable) return } if !allowed { c.Header("Retry-After", "900") failure(c, http.StatusTooManyRequests, "RATE_LIMITED", "登录尝试过于频繁,请稍后再试。", true) return } result, err := options.Auth.Login(c.Request.Context(), input.LoginName, input.Password, audience) if err != nil { fail(c, err) return } if audience == "admin" { http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: result.Token, Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 8 * 60 * 60, Expires: result.ExpiresAt}) success(c, http.StatusOK, gin.H{"user": result.User, "expires_at": result.ExpiresAt}) } else { success(c, http.StatusOK, gin.H{"user": result.User, "access_token": result.Token, "expires_at": result.ExpiresAt}) } } } r.POST("/api/admin/v1/auth/login", login("admin")) r.POST("/api/v1/auth/login", login("app")) logout := func(audience string) gin.HandlerFunc { return func(c *gin.Context) { if audience == "admin" && !sameOrigin(c.Request) { fail(c, apperror.ErrForbidden) return } token := requestToken(c.Request, audience) if err := options.Auth.Logout(c.Request.Context(), token, audience); err != nil { fail(c, err) return } if audience == "admin" { http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1}) } success(c, http.StatusOK, gin.H{"revoked": true}) } } r.POST("/api/admin/v1/auth/logout", adminAuth, logout("admin")) r.POST("/api/v1/auth/logout", appAuth, logout("app")) r.GET("/api/admin/v1/me", adminAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) }) r.GET("/api/v1/me", appAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) }) r.GET("/api/admin/v1/users", adminAuth, func(c *gin.Context) { users, err := options.Auth.ListUsers(c.Request.Context()) if err != nil { fail(c, err) return } success(c, http.StatusOK, users) }) r.GET("/api/v1/accounts", appAuth, func(c *gin.Context) { if options.Accounts == nil { fail(c, apperror.ErrUnavailable) return } list, err := options.Accounts.List(c.Request.Context(), currentUser(c).ID) if err != nil { fail(c, err) return } success(c, http.StatusOK, list) }) r.POST("/api/v1/accounts", appAuth, func(c *gin.Context) { var input struct { Mode string `json:"mode"` Name string `json:"name"` InitialCash *string `json:"initial_cash"` } if !decode(c, &input) { return } if options.Accounts == nil { fail(c, apperror.ErrUnavailable) return } account, err := options.Accounts.Create(c.Request.Context(), currentUser(c).ID, input.Mode, input.Name, input.InitialCash) if err != nil { fail(c, err) return } success(c, http.StatusCreated, account) }) r.GET("/api/v1/accounts/:id/summary", appAuth, func(c *gin.Context) { if options.Accounts == nil { fail(c, apperror.ErrUnavailable) return } account, err := options.Accounts.Get(c.Request.Context(), currentUser(c).ID, c.Param("id")) if err != nil { fail(c, err) return } success(c, http.StatusOK, account) }) } func authenticate(c *gin.Context, options IdentityOptions, audience string) { if options.Auth == nil { fail(c, apperror.ErrUnavailable) return } token := requestToken(c.Request, audience) if token == "" { fail(c, apperror.ErrUnauthenticated) return } user, err := options.Auth.Authenticate(c.Request.Context(), token, audience) if err != nil { fail(c, err) return } c.Set("user", user) c.Next() } func requestToken(r *http.Request, audience string) string { if audience == "admin" { cookie, err := r.Cookie(sessionCookie) if err == nil { return cookie.Value } return "" } scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ") if !ok || !strings.EqualFold(scheme, "Bearer") { return "" } return token } func currentUser(c *gin.Context) model.User { return c.MustGet("user").(model.User) } // The embedded console and Vite development proxy both call APIs on their own origin. func sameOrigin(r *http.Request) bool { origin, err := url.Parse(r.Header.Get("Origin")) if err != nil || origin.Host == "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" { return false } scheme := "http" if r.TLS != nil { scheme = "https" } return origin.Scheme == scheme && origin.Host == r.Host } func decode(c *gin.Context, value any) bool { return decodeLimit(c, value, 16<<10) } func decodeLimit(c *gin.Context, value any, limit int64) bool { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") { failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false) return false } decoder := json.NewDecoder(c.Request.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(value); err != nil { fail(c, apperror.ErrInvalidInput) return false } var extra any if err := decoder.Decode(&extra); err != io.EOF { fail(c, apperror.ErrInvalidInput) return false } return true } func success(c *gin.Context, status int, data any) { c.JSON(status, gin.H{"data": data, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID"), "server_time": time.Now().UTC()}}) } func failure(c *gin.Context, status int, code, message string, retryable bool) { c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": message, "retryable": retryable}, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID")}}) } func fail(c *gin.Context, err error) { switch { case errors.Is(err, apperror.ErrInvalidInput): failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入字段、必填项与格式。", false) case errors.Is(err, apperror.ErrUnauthenticated): failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false) case errors.Is(err, apperror.ErrForbidden): failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false) case errors.Is(err, apperror.ErrInsufficientCash): failure(c, http.StatusConflict, "INSUFFICIENT_CASH", "可用现金不足,请检查其他计划保留的预算。", false) case errors.Is(err, apperror.ErrInsufficientQuantity): failure(c, http.StatusConflict, "INSUFFICIENT_SELLABLE_QUANTITY", "可卖数量不足;请检查模拟可卖时间与剩余持仓。", false) case errors.Is(err, apperror.ErrCandidateUnavailable): failure(c, http.StatusConflict, "CANDIDATE_UNAVAILABLE", "候选已失效或版本已更新,请重新查看依据。", false) case errors.Is(err, apperror.ErrPreviewExpired): failure(c, http.StatusConflict, "PREVIEW_EXPIRED", "预算预览已过期,请重新预览后确认。", false) case errors.Is(err, apperror.ErrChronology): failure(c, http.StatusConflict, "FILL_CHRONOLOGY", "请按成交发生时间顺序录入;更早记录需先纠正后续成交。", false) case errors.Is(err, apperror.ErrDependentFill): failure(c, http.StatusConflict, "DEPENDENT_FILL", "后续成交依赖此记录,请先冲正最新成交。", false) case errors.Is(err, apperror.ErrConflict): failure(c, http.StatusConflict, "CONFLICT", "记录已存在或版本已更新,请刷新后重试。", false) case errors.Is(err, apperror.ErrNotFound): failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false) default: failure(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "服务暂不可用;请检查依赖连接和数据库迁移。", true) } }