init: initial commit
Checks / verify (push) Canceled after 41s

This commit is contained in:
2026-10-06 11:24:02 +08:00
parent 456e87d4f2
commit ba6dfffab7
63 changed files with 7223 additions and 0 deletions
@@ -0,0 +1,252 @@
package http
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"veyra/services/api/internal/platform/apperror"
"github.com/gin-gonic/gin"
"veyra/services/api/internal/identity/domain"
identity "veyra/services/api/internal/identity/service"
portfolio "veyra/services/api/internal/portfolio/service"
)
type LoginLimiter interface {
Login(context.Context, string, string) (bool, error)
}
type IdentityOptions struct {
Auth *identity.AuthService
Accounts *portfolio.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) domain.User { return c.MustGet("user").(domain.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 {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
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", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", 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.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)
}
}