feat: 完成模拟交易闭环、App 与 A 股行情接入
Checks / verify (push) Failing after 9m52s

统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
2026-10-08 22:58:52 +08:00
parent bffdb3858b
commit 92329ddce2
145 changed files with 18593 additions and 587 deletions
+75
View File
@@ -0,0 +1,75 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/gin-gonic/gin"
"net/http"
)
func bindBacktests(r *gin.Engine, o IdentityOptions) {
g := r.Group("/api/admin/v1/backtests", func(c *gin.Context) { authenticate(c, o, "admin") })
g.Use(func(c *gin.Context) {
if o.Backtests == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
g.GET("", func(c *gin.Context) {
v, e := o.Backtests.List(c.Request.Context(), currentUser(c))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusOK, v)
})
g.GET("/:id", func(c *gin.Context) {
v, e := o.Backtests.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusOK, v)
})
g.POST("", func(c *gin.Context) {
if !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
var input model.BacktestInput
if !decodeLimit(c, &input, 256<<10) {
return
}
v, e := o.Backtests.Create(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusAccepted, v)
})
g.POST("/from-market", func(c *gin.Context) {
if !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
var input struct {
StrategyVersionID string `json:"strategy_version_id"`
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
Start string `json:"start"`
End string `json:"end"`
InitialCash string `json:"initial_cash"`
}
if !decode(c, &input) {
return
}
v, e := o.Backtests.CreateMarket(c.Request.Context(), currentUser(c), input.StrategyVersionID, input.Symbol, input.Exchange, input.Start, input.End, input.InitialCash, c.Writer.Header().Get("X-Request-ID"))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusAccepted, v)
})
}
+99
View File
@@ -0,0 +1,99 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
)
func bindCandidates(r *gin.Engine, options IdentityOptions) {
for _, admin := range []bool{true, false} {
path, audience := "/api/v1", "app"
if admin {
path, audience = "/api/admin/v1", "admin"
}
group := r.Group(path, func(c *gin.Context) { authenticate(c, options, audience) })
group.Use(func(c *gin.Context) {
if options.Candidates == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
group.GET("/candidates", func(c *gin.Context) {
limit := 100
if raw := c.Query("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil {
fail(c, apperror.ErrInvalidInput)
return
}
limit = value
}
result, err := options.Candidates.List(c.Request.Context(), currentUser(c), admin, c.Query("status"), limit)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/candidates/:id", func(c *gin.Context) {
result, err := options.Candidates.Detail(c.Request.Context(), currentUser(c), c.Param("id"), admin)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
if !admin {
continue
}
group.Use(func(c *gin.Context) {
if !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
c.Next()
})
group.POST("/candidates", func(c *gin.Context) {
var input model.CandidateDraftInput
if !decode(c, &input) {
return
}
result, err := options.Candidates.Create(c.Request.Context(), currentUser(c), "", c.Writer.Header().Get("X-Request-ID"), input, 0)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, result)
})
group.POST("/candidates/:id/revisions", func(c *gin.Context) {
var input model.CandidateRevisionInput
if !decode(c, &input) {
return
}
result, err := options.Candidates.Create(c.Request.Context(), currentUser(c), c.Param("id"), c.Writer.Header().Get("X-Request-ID"), input.CandidateDraftInput, input.ExpectedVersion)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, result)
})
for endpoint, action := range map[string]string{"submit": "SUBMIT", "publish": "PUBLISH", "return": "RETURN", "withdraw": "WITHDRAW"} {
group.POST("/candidates/:id/"+endpoint, func(c *gin.Context) {
var input model.CandidateActionInput
if !decode(c, &input) {
return
}
result, err := options.Candidates.Transition(c.Request.Context(), currentUser(c), c.Param("id"), action, c.Writer.Header().Get("X-Request-ID"), input)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
}
}
}
+27 -3
View File
@@ -21,7 +21,15 @@ 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
@@ -209,7 +217,11 @@ func sameOrigin(r *http.Request) bool {
}
func decode(c *gin.Context, value any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
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
@@ -237,13 +249,25 @@ func failure(c *gin.Context, status int, code, message string, retryable bool) {
func fail(c *gin.Context, err error) {
switch {
case errors.Is(err, apperror.ErrInvalidInput):
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", false)
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)
failure(c, http.StatusConflict, "CONFLICT", "记录已存在或版本已更新,请刷新后重试。", false)
case errors.Is(err, apperror.ErrNotFound):
failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false)
default:
+3 -4
View File
@@ -2,8 +2,7 @@ package handler
import (
"context"
"io"
"log/slog"
"go.uber.org/zap"
"net/http"
"net/http/httptest"
"strings"
@@ -18,7 +17,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
t.Fatal(err)
}
probe := func(context.Context) error { return nil }
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true})
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{Auth: auth, DevelopmentLogin: true})
for _, tc := range []struct {
method, path, body, origin string
status int
@@ -43,7 +42,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
func TestDevelopmentLoginDisabledByDefault(t *testing.T) {
probe := func(context.Context) error { return nil }
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`)))
if rec.Code != http.StatusServiceUnavailable {
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"github.com/gin-gonic/gin"
"net/http"
)
func bindMarket(r *gin.Engine, o IdentityOptions) {
for _, audience := range []string{"admin", "app"} {
prefix := "/api/v1"
if audience == "admin" {
prefix = "/api/admin/v1"
}
g := r.Group(prefix, func(c *gin.Context) { authenticate(c, o, audience) })
g.Use(func(c *gin.Context) {
if o.Market == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
g.GET("/instruments", func(c *gin.Context) {
v, e := o.Market.List(c.Request.Context(), currentUser(c), c.Query("q"))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusOK, v)
})
g.GET("/market/history", func(c *gin.Context) {
v, e := o.Market.History(c.Request.Context(), currentUser(c), c.Query("symbol"), c.Query("exchange"), c.Query("start"), c.Query("end"))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusOK, v)
})
if audience == "admin" {
g.GET("/market/syncs", func(c *gin.Context) {
v, e := o.Market.Tasks(c.Request.Context(), currentUser(c))
if e != nil {
fail(c, e)
return
}
success(c, http.StatusOK, v)
})
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/gin-gonic/gin"
"net/http"
)
func bindNotifications(r *gin.Engine, options IdentityOptions) {
for _, admin := range []bool{true, false} {
path, audience := "/api/v1", "app"
if admin {
path, audience = "/api/admin/v1", "admin"
}
group := r.Group(path, func(c *gin.Context) { authenticate(c, options, audience) })
group.Use(func(c *gin.Context) {
if options.Notifications == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
if admin {
group.GET("/notification-tasks", func(c *gin.Context) {
result, err := options.Notifications.Tasks(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/simulation-quotes", func(c *gin.Context) {
result, err := options.Notifications.Quotes(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.POST("/simulation-quotes", func(c *gin.Context) {
if !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
var input service.SimulationQuoteInput
if !decode(c, &input) {
return
}
result, err := options.Notifications.SaveQuote(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, result)
})
} else {
group.GET("/notifications", func(c *gin.Context) {
result, err := options.Notifications.List(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.POST("/notifications/:id/read", func(c *gin.Context) {
var input struct{}
if !decode(c, &input) {
return
}
if err := options.Notifications.MarkRead(c.Request.Context(), currentUser(c), c.Param("id")); err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, gin.H{"read": true})
})
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/gin-gonic/gin"
"net/http"
)
func bindProviders(r *gin.Engine, options IdentityOptions) {
admin := r.Group("/api/admin/v1", func(c *gin.Context) { authenticate(c, options, "admin") })
admin.Use(func(c *gin.Context) {
if options.Providers == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
admin.GET("/providers", func(c *gin.Context) {
list, err := options.Providers.List(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, list)
})
admin.PUT("/providers/:key", func(c *gin.Context) {
if !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
var input service.ProviderInput
if !decode(c, &input) {
return
}
config, err := options.Providers.Save(c.Request.Context(), currentUser(c), c.Param("key"), c.Writer.Header().Get("X-Request-ID"), input)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, config)
})
admin.GET("/audit-events", func(c *gin.Context) {
list, err := options.Providers.Audit(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, list)
})
}
+29 -4
View File
@@ -3,7 +3,7 @@ package handler
import (
"context"
"log/slog"
"go.uber.org/zap"
"net/http"
"strings"
"time"
@@ -15,7 +15,7 @@ import (
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler {
func NewRouter(health *service.HealthService, logger *zap.Logger, identity IdentityOptions) http.Handler {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(func(c *gin.Context) {
@@ -29,10 +29,27 @@ func NewRouter(health *service.HealthService, logger *slog.Logger, identity Iden
start := time.Now()
defer func() {
if recover() != nil {
logger.Error("request panic", "request_id", requestID)
logger.Error("request panic", zap.String("request_id", requestID))
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}})
}
logger.Info("http request", "method", c.Request.Method, "route", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", requestID)
route := c.FullPath()
if route == "" {
route = "/<unmatched>"
if c.Request.URL.Path == "/" {
route = "/"
} else if strings.HasPrefix(c.Request.URL.Path, "/assets/") {
route = "/assets/*"
}
}
fields := []zap.Field{zap.String("method", c.Request.Method), zap.String("route", route), zap.Int("status", c.Writer.Status()), zap.Int64("duration_ms", time.Since(start).Milliseconds()), zap.String("request_id", requestID)}
switch {
case c.Writer.Status() >= 500:
logger.Error("HTTP request", fields...)
case c.Writer.Status() >= 400:
logger.Warn("HTTP request", fields...)
default:
logger.Info("HTTP request", fields...)
}
}()
c.Next()
})
@@ -48,6 +65,14 @@ func NewRouter(health *service.HealthService, logger *slog.Logger, identity Iden
r.GET("/readyz", check)
r.GET("/api/v1/system/health", check)
bindIdentity(r, identity)
bindProviders(r, identity)
bindCandidates(r, identity)
bindTradePlans(r, identity)
bindTradeFills(r, identity)
bindNotifications(r, identity)
bindStrategies(r, identity)
bindBacktests(r, identity)
bindMarket(r, identity)
console := adminui.Handler()
r.NoRoute(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" {
+4 -5
View File
@@ -3,8 +3,7 @@ package handler
import (
"context"
"errors"
"io"
"log/slog"
"go.uber.org/zap"
"net/http"
"net/http/httptest"
"strings"
@@ -16,7 +15,7 @@ import (
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
secretError := "password=secret-internal-detail"
health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) })
router := NewRouter(health, slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
router := NewRouter(health, zap.NewNop(), IdentityOptions{})
for _, tc := range []struct {
path string
status int
@@ -37,7 +36,7 @@ func TestLivenessAndReadinessAreIndependent(t *testing.T) {
func TestReadyWhenBothDependenciesRespond(t *testing.T) {
probe := func(context.Context) error { return nil }
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusOK {
@@ -47,7 +46,7 @@ func TestReadyWhenBothDependenciesRespond(t *testing.T) {
func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) {
probe := func(context.Context) error { return nil }
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
for _, path := range []string{"/", "/research/candidates"} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+73
View File
@@ -0,0 +1,73 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/gin-gonic/gin"
"net/http"
)
func bindStrategies(r *gin.Engine, options IdentityOptions) {
group := r.Group("/api/admin/v1", func(c *gin.Context) { authenticate(c, options, "admin") })
group.Use(func(c *gin.Context) {
if options.Strategies == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
group.GET("/strategies", func(c *gin.Context) {
result, err := options.Strategies.List(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategies/:id", func(c *gin.Context) {
result, err := options.Strategies.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.Use(func(c *gin.Context) {
if !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
c.Next()
})
for _, path := range []string{"/strategies", "/strategies/:id/versions"} {
group.POST(path, func(c *gin.Context) {
var input service.StrategyInput
if !decode(c, &input) {
return
}
result, err := options.Strategies.Save(c.Request.Context(), currentUser(c), c.Param("id"), input, c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, result)
})
}
group.POST("/strategies/:id/deploy", func(c *gin.Context) {
var input struct {
VersionID string `json:"version_id"`
Status string `json:"status"`
ExpectedVersion int64 `json:"expected_version"`
Reason string `json:"reason"`
}
if !decode(c, &input) {
return
}
result, err := options.Strategies.Deploy(c.Request.Context(), currentUser(c), c.Param("id"), input.VersionID, input.Status, input.ExpectedVersion, input.Reason, c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
}
+61
View File
@@ -0,0 +1,61 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/gin-gonic/gin"
"net/http"
)
func bindTradeFills(r *gin.Engine, options IdentityOptions) {
group := r.Group("/api/v1", func(c *gin.Context) { authenticate(c, options, "app") })
group.Use(func(c *gin.Context) {
if options.Fills == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
group.POST("/fills", func(c *gin.Context) {
var input service.FillInput
if !decode(c, &input) {
return
}
result, err := options.Fills.Record(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, result)
})
group.POST("/fills/:id/reverse", func(c *gin.Context) {
var input struct {
Reason string `json:"reason"`
}
if !decode(c, &input) {
return
}
result, err := options.Fills.Reverse(c.Request.Context(), currentUser(c), c.Param("id"), input.Reason, c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/accounts/:id/fills", func(c *gin.Context) {
result, err := options.Fills.List(c.Request.Context(), currentUser(c), c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/accounts/:id/portfolio", func(c *gin.Context) {
result, err := options.Fills.Portfolio(c.Request.Context(), currentUser(c), c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
}
+73
View File
@@ -0,0 +1,73 @@
package handler
import (
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/gin-gonic/gin"
"net/http"
)
func bindTradePlans(r *gin.Engine, options IdentityOptions) {
group := r.Group("/api/v1", func(c *gin.Context) { authenticate(c, options, "app") })
group.Use(func(c *gin.Context) {
if options.Plans == nil {
fail(c, apperror.ErrUnavailable)
return
}
c.Next()
})
group.POST("/plan-previews", func(c *gin.Context) {
var input service.PlanPreviewInput
if !decode(c, &input) {
return
}
result, err := options.Plans.Preview(c.Request.Context(), currentUser(c), input)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, result)
})
group.POST("/plan-previews/:id/confirm", func(c *gin.Context) {
var input struct{}
if !decode(c, &input) {
return
}
result, err := options.Plans.Confirm(c.Request.Context(), currentUser(c), c.Param("id"), c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/plans", func(c *gin.Context) {
result, err := options.Plans.List(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.POST("/plans/:id/cancel", func(c *gin.Context) {
var input struct {
ExpectedVersion int64 `json:"expected_version"`
}
if !decode(c, &input) {
return
}
result, err := options.Plans.Cancel(c.Request.Context(), currentUser(c), c.Param("id"), input.ExpectedVersion, c.Writer.Header().Get("X-Request-ID"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/accounts/:id/budget", func(c *gin.Context) {
result, err := options.Plans.Budget(c.Request.Context(), currentUser(c), c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
}