package service import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model" "github.com/google/uuid" "regexp" "strings" "time" "unicode/utf8" ) type TradeFillRepository interface { Record(context.Context, string, model.TradeFill, string) (model.TradeFill, error) List(context.Context, string, string) ([]model.TradeFill, error) Reverse(context.Context, string, string, string, string) (model.TradeReversal, error) Portfolio(context.Context, string, string) (model.PortfolioSummary, error) } type TradeFillService struct { repo TradeFillRepository development bool } func NewTradeFillService(repo TradeFillRepository, development bool) *TradeFillService { return &TradeFillService{repo, development} } func (s *TradeFillService) authorize(actor model.User) error { if actor.Role != "USER" || actor.Status != "ACTIVE" { return apperror.ErrForbidden } if s == nil || s.repo == nil || !s.development { return apperror.ErrUnavailable } return nil } type FillInput struct { AccountID string `json:"account_id"` PlanID *string `json:"plan_id"` IdempotencyKey string `json:"idempotency_key"` Side string `json:"side"` Symbol string `json:"symbol"` Exchange string `json:"exchange"` Name string `json:"name"` Quantity string `json:"quantity"` Price string `json:"price"` Fee string `json:"fee"` OccurredAt time.Time `json:"occurred_at"` SellableAt *time.Time `json:"sellable_at"` Note string `json:"note"` } var fillSymbolPattern = regexp.MustCompile(`^[0-9]{6}$`) func (s *TradeFillService) Record(ctx context.Context, actor model.User, input FillInput, requestID string) (model.TradeFill, error) { if err := s.authorize(actor); err != nil { return model.TradeFill{}, err } for _, id := range []string{input.AccountID, input.IdempotencyKey, requestID} { if _, err := uuid.Parse(id); err != nil { return model.TradeFill{}, apperror.ErrInvalidInput } } if input.PlanID != nil { if _, err := uuid.Parse(*input.PlanID); err != nil { return model.TradeFill{}, apperror.ErrInvalidInput } } qty, err := ParseQuantity(input.Quantity) if err != nil { return model.TradeFill{}, err } price, err := model.ParseMoney(input.Price) if err != nil || price == 0 { return model.TradeFill{}, apperror.ErrInvalidInput } fee, err := model.ParseMoney(input.Fee) if err != nil { return model.TradeFill{}, err } gross := price * qty if gross > 100000000000 || gross+fee > 100000000000 { return model.TradeFill{}, apperror.ErrInvalidInput } if !fillSymbolPattern.MatchString(input.Symbol) || (input.Exchange != "SSE" && input.Exchange != "SZSE") || strings.TrimSpace(input.Name) == "" || utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.Note) > 1000 || input.OccurredAt.IsZero() || input.OccurredAt.After(time.Now().Add(time.Minute)) { return model.TradeFill{}, apperror.ErrInvalidInput } delta := gross - fee switch input.Side { case "BUY": if qty%100 != 0 || input.SellableAt == nil || input.SellableAt.Before(input.OccurredAt) { return model.TradeFill{}, apperror.ErrInvalidInput } delta = -gross - fee case "SELL": if input.SellableAt != nil || fee > gross || input.PlanID != nil { return model.TradeFill{}, apperror.ErrInvalidInput } default: return model.TradeFill{}, apperror.ErrInvalidInput } input.Price = model.FormatMoney(price) input.Fee = model.FormatMoney(fee) input.Name = strings.TrimSpace(input.Name) input.Note = strings.TrimSpace(input.Note) input.OccurredAt = input.OccurredAt.UTC() if input.SellableAt != nil { t := input.SellableAt.UTC() input.SellableAt = &t } encoded, err := json.Marshal(input) if err != nil { return model.TradeFill{}, apperror.ErrInvalidInput } digest := sha256.Sum256(encoded) fill := model.TradeFill{ID: uuid.NewString(), AccountID: input.AccountID, PlanID: input.PlanID, IdempotencyKey: input.IdempotencyKey, InputHash: hex.EncodeToString(digest[:]), Side: input.Side, Symbol: input.Symbol, Exchange: input.Exchange, Name: input.Name, Quantity: qty, PriceMinor: price, FeeMinor: fee, CashDeltaMinor: delta, OccurredAt: input.OccurredAt, SellableAt: input.SellableAt, Note: input.Note} return s.repo.Record(ctx, actor.ID, fill, requestID) } func (s *TradeFillService) List(ctx context.Context, actor model.User, account string) ([]model.TradeFill, error) { if err := s.authorize(actor); err != nil { return nil, err } if _, err := uuid.Parse(account); err != nil { return nil, apperror.ErrInvalidInput } return s.repo.List(ctx, actor.ID, account) } func (s *TradeFillService) Reverse(ctx context.Context, actor model.User, id, reason, requestID string) (model.TradeReversal, error) { if err := s.authorize(actor); err != nil { return model.TradeReversal{}, err } if _, err := uuid.Parse(id); err != nil { return model.TradeReversal{}, apperror.ErrInvalidInput } if err := model.ValidateReason(reason); err != nil { return model.TradeReversal{}, err } return s.repo.Reverse(ctx, actor.ID, id, reason, requestID) } func (s *TradeFillService) Portfolio(ctx context.Context, actor model.User, account string) (model.PortfolioSummary, error) { if err := s.authorize(actor); err != nil { return model.PortfolioSummary{}, err } if _, err := uuid.Parse(account); err != nil { return model.PortfolioSummary{}, apperror.ErrInvalidInput } return s.repo.Portfolio(ctx, actor.ID, account) }