285 lines
9.9 KiB
Go
285 lines
9.9 KiB
Go
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 {
|
|
Analyses *service.CandidateAnalysisService
|
|
Screenings *service.ScreeningService
|
|
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
|
|
PublicOrigin string
|
|
}
|
|
|
|
const sessionCookie = "veyra_admin_session"
|
|
|
|
type publicOriginContextKey struct{}
|
|
|
|
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
|
|
}
|
|
if expected, _ := r.Context().Value(publicOriginContextKey{}).(string); expected != "" {
|
|
return origin.Host == r.Host && origin.Scheme+"://"+origin.Host == expected
|
|
}
|
|
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)
|
|
}
|
|
}
|