package lkemulator import ( "context" "crypto/rand" "embed" "encoding/base64" "encoding/json" "errors" "fmt" "html/template" "io" "log" "net/http" "strconv" "strings" "time" ) //go:embed web/templates/*.html var tplFS embed.FS // ServerConfig — настройки эмулятора ЛК. type ServerConfig struct { Addr string GatewayURL string SelfPublicURL string // адрес, который мы сообщим gateway для callback'ов } // Server — HTTP-сервер эмулятора. type Server struct { cfg ServerConfig store *Store gw *GatewayClient home, new, claim *template.Template srv *http.Server mux *http.ServeMux } // NewServer собирает Server. func NewServer(cfg ServerConfig) (*Server, error) { parse := func(content string) (*template.Template, error) { return template.ParseFS(tplFS, "web/templates/layout.html", "web/templates/"+content) } home, err := parse("home.html") if err != nil { return nil, fmt.Errorf("parse home: %w", err) } newTpl, err := parse("new.html") if err != nil { return nil, fmt.Errorf("parse new: %w", err) } claimTpl, err := parse("claim.html") if err != nil { return nil, fmt.Errorf("parse claim: %w", err) } s := &Server{ cfg: cfg, store: NewStore(), gw: NewGatewayClient(cfg.GatewayURL), home: home, new: newTpl, claim: claimTpl, mux: http.NewServeMux(), } s.routes() s.srv = &http.Server{ Addr: cfg.Addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second, } return s, nil } // Run поднимает сервер, регистрирует свой URL у gateway, ждёт ctx.Done(). func (s *Server) Run(ctx context.Context) error { go func() { // Дать серверу подняться, потом попытаться зарегистрировать callback URL. time.Sleep(200 * time.Millisecond) if s.cfg.SelfPublicURL != "" { if err := s.gw.SetCallbackURL(ctx, s.cfg.SelfPublicURL); err != nil { log.Printf("lk-emulator: не получилось зарегистрировать callback URL: %v", err) } else { log.Printf("lk-emulator: callback URL %s зарегистрирован в lk-gateway", s.cfg.SelfPublicURL) } } }() errCh := make(chan error, 1) go func() { log.Printf("lk-emulator: listen %s", s.cfg.Addr) errCh <- s.srv.ListenAndServe() }() select { case <-ctx.Done(): shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.srv.Shutdown(shutCtx) return nil case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { return nil } return err } } // Mux возвращает обработчик (для httptest). func (s *Server) Mux() http.Handler { return s.mux } // Store возвращает store (для тестов). func (s *Server) Store() *Store { return s.store } func (s *Server) routes() { s.mux.HandleFunc("/", s.handleHome) s.mux.HandleFunc("/new", s.handleNew) s.mux.HandleFunc("/claims/", s.handleClaim) s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) // Принимаем callback'и от lk-gateway (PATCH). s.mux.HandleFunc("/api/v1/back_office/claims/", s.handleCallback) } type pageData struct { Title string Active string GatewayURL string AutoRefresh bool Flash string Error string } func (s *Server) basePage(title, active string, autoRefresh bool) pageData { return pageData{Title: title, Active: active, GatewayURL: s.cfg.GatewayURL, AutoRefresh: autoRefresh} } type homeData struct { pageData Claims []*Claim } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } data := homeData{ pageData: s.basePage("Мои заявки", "home", true), Claims: s.store.All(), } if msg := r.URL.Query().Get("flash"); msg != "" { data.Flash = msg } s.renderTpl(w, s.home, data) } // clientView — DTO от gateway для выпадающего списка инвесторов. type clientView struct { ID string `json:"ID"` LastName string `json:"LastName"` FirstName string `json:"FirstName"` MiddleName string `json:"MiddleName"` } type newData struct { pageData Clients []clientView } func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { clients, err := s.fetchClients(r.Context()) if err != nil { s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: nil}) return } s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients}) return } if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } if err := s.submitNew(r); err != nil { clients, _ := s.fetchClients(r.Context()) d := newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients} d.Error = err.Error() s.renderTpl(w, s.new, d) return } http.Redirect(w, r, "/?flash=Заявка+отправлена+в+lk-gateway.+Ждём+callback...", http.StatusSeeOther) } func (s *Server) submitNew(r *http.Request) error { if err := r.ParseForm(); err != nil { return fmt.Errorf("разбор формы: %w", err) } investorID := r.FormValue("investor_id") if investorID == "" { return errors.New("укажите инвестора") } clients, err := s.fetchClients(r.Context()) if err != nil { return fmt.Errorf("список инвесторов: %w", err) } investorName := "" for _, c := range clients { if c.ID == investorID { investorName = strings.TrimSpace(c.LastName + " " + c.FirstName + " " + c.MiddleName) break } } tInn := r.FormValue("transferring_depository_inn") rInn := r.FormValue("receiving_depository_inn") secCode := r.FormValue("security_code") isin := r.FormValue("isin") qStr := r.FormValue("quantity") whole, err := strconv.ParseUint(qStr, 10, 64) if err != nil || whole == 0 { return errors.New("количество должно быть положительным целым") } body := map[string]any{ "investor": map[string]any{ "id": investorID, "last_name": splitFio(investorName, 0), "first_name": splitFio(investorName, 1), "document": map[string]any{ "document_type": "21", "series": "4512", "number": "654321", }, }, "transferring_depository_inn": tInn, "receiving_depository_inn": rInn, "securities": []any{ map[string]any{ "security_code": secCode, "security_details": map[string]any{"isin": isin}, "quantity": map[string]any{"whole": whole}, "settlement_accounts": []any{ map[string]any{ "settlement_requisites_inn": "7702070139", "settlement_location": map[string]any{ "deponent_code": "DP789456", "account_id": "31MC0021900000F01", "section_id": "P001", }, }, }, }, }, "signed_document": base64.StdEncoding.EncodeToString([]byte("demo")), "signature_format": "XMLDSig-GOST", } if code := r.FormValue("cost_info_code"); code != "" { body["cost_info"] = map[string]any{"yes": map[string]any{"code": code}} } else { body["cost_info"] = map[string]any{"no": map[string]any{}} } if iiaType := r.FormValue("iia_type"); iiaType != "" { body["iia_agreement"] = map[string]any{ "agreement_type": iiaType, "agreement_number": r.FormValue("iia_number"), "agreement_date": r.FormValue("iia_date"), "broker_inn": r.FormValue("iia_broker_inn"), } } resp, err := s.gw.CreateClaim(r.Context(), body) if err != nil { return err } gwID, _ := resp["id"].(string) status, _ := resp["status"].(string) c := &Claim{ ID: randomID(), GatewayID: gwID, Status: status, LocalStatus: "submitted", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), InvestorName: investorName, TransferringDepositoryINN: tInn, ReceivingDepositoryINN: rInn, SecuritiesCount: 1, RawRequest: body, RawResponse: resp, } s.store.Add(c) return nil } type claimPage struct { pageData Claim *Claim PrettyRequest string PrettyResponse string } func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/claims/") if id == "" || strings.Contains(id, "/") { http.NotFound(w, r) return } c := s.store.Get(id) if c == nil { http.NotFound(w, r) return } d := claimPage{ pageData: s.basePage("Заявка", "home", c.LastCallback == nil), Claim: c, } if b, err := json.MarshalIndent(c.RawRequest, "", " "); err == nil { d.PrettyRequest = string(b) } if b, err := json.MarshalIndent(c.RawResponse, "", " "); err == nil { d.PrettyResponse = string(b) } s.renderTpl(w, s.claim, d) } // handleCallback принимает PATCH /api/v1/back_office/claims/{id} от lk-gateway. func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { http.Error(w, "method", http.StatusMethodNotAllowed) return } gwID := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/") if gwID == "" { http.Error(w, "id required", http.StatusBadRequest) return } defer r.Body.Close() raw, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var payload struct { NewStatus string `json:"new_status"` ReasonCode string `json:"reason_code"` ReasonText string `json:"reason_text"` UpdatedAt time.Time `json:"updated_at"` NSDResp *struct { GUID string `json:"guid"` StatusCode string `json:"status_code"` Responses []struct { ReferenceID string `json:"reference_id"` Code string `json:"code"` Text string `json:"text"` } `json:"responses"` } `json:"nsd_response"` } if err := json.Unmarshal(raw, &payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } cb := &CallbackRecord{ NewStatus: payload.NewStatus, ReasonCode: payload.ReasonCode, ReasonText: payload.ReasonText, UpdatedAt: payload.UpdatedAt, } if payload.NSDResp != nil { cb.GUID = payload.NSDResp.GUID cb.StatusCode = payload.NSDResp.StatusCode for _, rr := range payload.NSDResp.Responses { cb.Responses = append(cb.Responses, CallbackResponseEntry{ ReferenceID: rr.ReferenceID, Code: rr.Code, Text: rr.Text, }) } } if !s.store.ApplyCallback(gwID, cb) { http.Error(w, "claim not found in emulator", http.StatusNotFound) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"success":true}`)) } func (s *Server) fetchClients(ctx context.Context) ([]clientView, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.GatewayURL+"/admin/api/clients", nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var out []clientView if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } return out, nil } func (s *Server) renderTpl(w http.ResponseWriter, t *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "layout", data); err != nil { log.Printf("lk-emulator: render: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) } } // randomID — генерирует короткий локальный ID заявки (8 hex). func randomID() string { var b [4]byte _, _ = rand.Read(b[:]) return fmt.Sprintf("%x", b) } func splitFio(fio string, idx int) string { parts := strings.Fields(fio) if idx < len(parts) { return parts[idx] } return "" }